[Scummvm-git-logs] scummvm master -> 64d051dc08be08b230344d4d55de734304c9d5d4

neuromancer noreply at scummvm.org
Sun Jul 26 11:16:46 UTC 2026


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

Summary:
3340929b6c SCUMM: RA2: support for JP release (DOS/Windows)
c02839641b SCUMM: RA2: better implementation of the menu for psx release
8d355b9ef7 SCUMM: RA2: implementation of the level select menu for psx release
e3d7ec9751 SCUMM: RA2: use correct sound for L1 in psx release
8420adc2de SCUMM: RA2: improved movement and gameplay of L1 in psx release
68f27cca05 SCUMM: RA2: added correct explosions and debris among others in L1 in psx release
a5985cf827 SCUMM: RA2: added smoke trails and other effects in L1 in psx release
e182864bf7 SCUMM: RA2: added missing animation and improved controls for L1 in psx release
b9d3731531 SCUMM: RA2: use the original's fire cadence and cockpit details for L1 in psx release
d8041fdd6b SCUMM: RA2: pan the 3D scene with the view in L1 in psx release
1872ad720d SCUMM: RA2: sustain the looping sounds in psx release
22a782936c SCUMM: RA2: emit the debris smoke trails as the original does in psx release
233d70d6b1 SCUMM: RA2: initial code for L2 in psx release
61864ceb26 SCUMM: RA2: correctly implemented sliding effect and enemy waves in L2 in psx release
53a736900d SCUMM: RA2: fix L2 aiming, occlusion and per-part rookie tables in psx release
2a8dc23c1a SCUMM: RA2: fix L2 shot origin, cover occlusion and cutscene order in psx release
79b6a1e895 SCUMM: RA2: draw the L2 rookie with its slot CLUT and fire from the pistol in psx release
64d051dc08 SCUMM: RA2: fixed variable shadow warning in psx release


Commit: 3340929b6c56519c7ba1f790da9b0f4e077a4ee9
    https://github.com/scummvm/scummvm/commit/3340929b6c56519c7ba1f790da9b0f4e077a4ee9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: support for JP release (DOS/Windows)

Changed paths:
    engines/scumm/charset.cpp
    engines/scumm/detection_internal.h
    engines/scumm/insane/rebel2/iact.cpp
    engines/scumm/insane/rebel2/menu.cpp
    engines/scumm/insane/rebel2/render.cpp
    engines/scumm/smush/rebel/font_rebel2.cpp
    engines/scumm/smush/rebel/font_rebel2.h
    engines/scumm/smush/rebel/smush_multi_font.cpp


diff --git a/engines/scumm/charset.cpp b/engines/scumm/charset.cpp
index 42910601596..190e18181d0 100644
--- a/engines/scumm/charset.cpp
+++ b/engines/scumm/charset.cpp
@@ -116,7 +116,12 @@ void ScummEngine::loadCJKFont() {
 			numChar = 2350;
 			break;
 		case Common::JA_JPN:
-			fontFile = (_game.id == GID_DIG) ? "kanji16.fnt" : "japanese.fnt";
+			if (_game.id == GID_DIG)
+				fontFile = "kanji16.fnt";
+			else if (_game.id == GID_REBEL2)
+				fontFile = "LAUNCH/KANJI.FNT";
+			else
+				fontFile = "japanese.fnt";
 			numChar = 8192;
 			break;
 		case Common::ZH_TWN:
diff --git a/engines/scumm/detection_internal.h b/engines/scumm/detection_internal.h
index 77785688a06..e6b00513396 100644
--- a/engines/scumm/detection_internal.h
+++ b/engines/scumm/detection_internal.h
@@ -249,6 +249,8 @@ static Common::Language detectLanguage(const Common::FSList &fslist, byte id, co
 				&& searchFSNode(systmList, "GAME.TRS", langFile)
 				&& trs.open(langFile)) {
 				switch (trs.size()) {
+				case 46294:	// ed4b2312e8f60ad3fdd9d02db38da9a9
+					return Common::JA_JPN;
 				case 46746:	// d9aced0c3fcb8f6a0045dcd4cbf12590
 					return Common::EN_ANY;
 				case 48097:	// 66353d7250f680b28992459c355caa17
@@ -257,6 +259,8 @@ static Common::Language detectLanguage(const Common::FSList &fslist, byte id, co
 					return Common::ES_ESP;
 				case 50094:	// 004fb2fd15f84a1f81cc362d73811c9c
 					return Common::DE_DEU;
+				case 58883:	// efffbf955884a87a3be6b8459ba559de
+					return Common::PT_BRA;
 				case 60976:	// c53823d48beca122c45a83d35027a0e7
 					return Common::FR_FRA;
 				default:
diff --git a/engines/scumm/insane/rebel2/iact.cpp b/engines/scumm/insane/rebel2/iact.cpp
index 0a3c2dd89ab..0af5ddacdf5 100644
--- a/engines/scumm/insane/rebel2/iact.cpp
+++ b/engines/scumm/insane/rebel2/iact.cpp
@@ -2068,6 +2068,7 @@ void InsaneRebel2::iactRebel2Opcode9(byte *renderBitmap, Common::SeekableReadStr
 	int srcLen = strlen(textStr);
 	int dstIdx = 0;
 	int numChars = _rebelMsgFont->getNumChars();
+	const bool useCJK = _vm->_language == Common::JA_JPN;
 
 	for (int i = 0; i < srcLen && dstIdx < (int)sizeof(convertedText) - 1; i++) {
 		byte ch = (byte)textStr[i];
@@ -2104,6 +2105,16 @@ void InsaneRebel2::iactRebel2Opcode9(byte *renderBitmap, Common::SeekableReadStr
 			}
 		}
 
+		uint charLen;
+		decodeRebel2Char(textStr + i, srcLen - i, charLen, useCJK);
+		if (charLen == 2) {
+			if (dstIdx + 2 > (int)sizeof(convertedText) - 1)
+				break;
+			convertedText[dstIdx++] = textStr[i++];
+			convertedText[dstIdx++] = textStr[i];
+			continue;
+		}
+
 		if (ch >= 'a' && ch <= 'z') {
 			ch = ch - 'a' + 'A';
 		}
@@ -2119,7 +2130,7 @@ void InsaneRebel2::iactRebel2Opcode9(byte *renderBitmap, Common::SeekableReadStr
 	convertedText[dstIdx] = '\0';
 
 	if (ConfMan.getBool("subtitles")) {
-		Rebel2FontSet fontSet;
+		Rebel2FontSet fontSet(_vm->_language == Common::JA_JPN);
 		fontSet.numFonts = 1;
 		fontSet.fonts[0] = _rebelMsgFont;
 
diff --git a/engines/scumm/insane/rebel2/menu.cpp b/engines/scumm/insane/rebel2/menu.cpp
index 51bbee2f9cd..c646f42aa59 100644
--- a/engines/scumm/insane/rebel2/menu.cpp
+++ b/engines/scumm/insane/rebel2/menu.cpp
@@ -291,17 +291,21 @@ int InsaneRebel2::getMenuStringWidth(const char *str) const {
 	if (!defaultFont)
 		return 0;
 
+	const char *end = str + strlen(str);
+	const bool useCJK = _vm->_language == Common::JA_JPN;
 	int w = 0;
 	NutRenderer *curFont = defaultFont;
 	int dummyColor = 0;
-	while (*str) {
+	while (str < end) {
 		int fc = parseFormatCode(str, dummyColor);
 		if (fc >= 0) { curFont = (fonts[fc] ? fonts[fc] : defaultFont); continue; }
 		if (fc == -2) continue;
-		byte c = (byte)*str++;
+		uint charLen;
+		uint16 c = decodeRebel2Char(str, end - str, charLen, useCJK);
+		str += charLen;
 		if (c >= 'a' && c <= 'z') c = c - 'a' + 'A';
-		if (curFont && c < curFont->getNumChars())
-			w += curFont->getCharWidth(c);
+		if (curFont && (c > 0xff || c < curFont->getNumChars()))
+			w += curFont->getCharWidth((byte)c);
 	}
 	return w;
 }
@@ -315,16 +319,20 @@ void InsaneRebel2::drawMenuString(byte *renderBitmap, const char *str, int x, in
 	Common::Rect clipRect(0, 0, _vm->_screenWidth, _vm->_screenHeight);
 	int pitch = _vm->_screenWidth;
 
+	const char *end = str + strlen(str);
+	const bool useCJK = _vm->_language == Common::JA_JPN;
 	NutRenderer *curFont = defaultFont;
 	int curColor = defaultColor;
-	while (*str) {
+	while (str < end) {
 		int fc = parseFormatCode(str, curColor);
 		if (fc >= 0) { curFont = (fonts[fc] ? fonts[fc] : defaultFont); continue; }
 		if (fc == -2) continue;
-		byte c = (byte)*str++;
+		uint charLen;
+		uint16 c = decodeRebel2Char(str, end - str, charLen, useCJK);
+		str += charLen;
 		if (c >= 'a' && c <= 'z') c = c - 'a' + 'A';
-		if (!curFont || c >= curFont->getNumChars()) continue;
-		int charW = curFont->getCharWidth(c);
+		if (!curFont || (c <= 0xff && c >= curFont->getNumChars())) continue;
+		int charW = curFont->getCharWidth((byte)c);
 		if (x >= 0 && y >= 0 && charW > 0)
 			drawRebel2Char(curFont, renderBitmap, clipRect, x, y, pitch, curColor, c);
 		x += charW;
diff --git a/engines/scumm/insane/rebel2/render.cpp b/engines/scumm/insane/rebel2/render.cpp
index c5447b4de25..d3353bd2482 100644
--- a/engines/scumm/insane/rebel2/render.cpp
+++ b/engines/scumm/insane/rebel2/render.cpp
@@ -2795,6 +2795,7 @@ void InsaneRebel2::renderTextOverlay(byte *renderBitmap, int pitch, int width, i
 	}
 
 	const int textScale = isHiRes() ? 2 : 1;
+	const bool useCJK = _vm->_language == Common::JA_JPN;
 	int drawY = _textOverlayY * textScale;
 	int visCount = 0;
 
@@ -2813,11 +2814,13 @@ void InsaneRebel2::renderTextOverlay(byte *renderBitmap, int pitch, int width, i
 				if (parseRebel2TextOverlayFormat(s, mFont, mColor, fonts, ARRAYSIZE(fonts), defaultFont))
 					continue;
 				lineFont = mFont;
-				byte c = (byte)*s++;
+				uint charLen;
+				uint16 c = decodeRebel2Char(s, lineEnd - s, charLen, useCJK);
+				s += charLen;
 				if (c >= 'a' && c <= 'z')
 					c = c - 'a' + 'A';
-				if (mFont && c < mFont->getNumChars())
-					lineWidth += mFont->getCharWidth(c);
+				if (mFont && (c > 0xff || c < mFont->getNumChars()))
+					lineWidth += mFont->getCharWidth((byte)c);
 				lineVisCount++;
 			}
 		}
@@ -2831,14 +2834,16 @@ void InsaneRebel2::renderTextOverlay(byte *renderBitmap, int pitch, int width, i
 			while (s < lineEnd && (visCount + lineCharsDrawn) < displayLen) {
 				if (parseRebel2TextOverlayFormat(s, curFont, curColor, fonts, ARRAYSIZE(fonts), defaultFont))
 					continue;
-				byte c = (byte)*s++;
+				uint charLen;
+				uint16 c = decodeRebel2Char(s, lineEnd - s, charLen, useCJK);
+				s += charLen;
 				if (c >= 'a' && c <= 'z')
 					c = c - 'a' + 'A';
-				if (!curFont || c >= curFont->getNumChars()) {
+				if (!curFont || (c <= 0xff && c >= curFont->getNumChars())) {
 					lineCharsDrawn++;
 					continue;
 				}
-				int charW = curFont->getCharWidth(c);
+				int charW = curFont->getCharWidth((byte)c);
 				if (drawX >= 0 && drawY >= 0 && charW > 0) {
 					drawRebel2Char(curFont, renderBitmap, clipRect, drawX, drawY, pitch, curColor, c);
 				}
@@ -4168,7 +4173,7 @@ void InsaneRebel2::renderHandler8PovOverlay(byte *renderBitmap, int pitch, int w
 	if (_rebelHandler != 8 || !renderBitmap || !_smush_talkfontNut || !_smush_povfontNut)
 		return;
 
-	Rebel2FontSet fontSet;
+	Rebel2FontSet fontSet(_vm->_language == Common::JA_JPN);
 	fontSet.numFonts = 4;
 	fontSet.defaultFont = 0;
 	fontSet.fonts[0] = _smush_talkfontNut;
diff --git a/engines/scumm/smush/rebel/font_rebel2.cpp b/engines/scumm/smush/rebel/font_rebel2.cpp
index 77f2733eeaf..7b903401e63 100644
--- a/engines/scumm/smush/rebel/font_rebel2.cpp
+++ b/engines/scumm/smush/rebel/font_rebel2.cpp
@@ -637,7 +637,7 @@ bool drawRebel2Codec23Sprite(NutRenderer *sprite, byte *buffer, int pitch, int w
 	return renderer && renderer->drawCodec23Sprite(buffer, pitch, width, height, clipRect, x, y, spriteIdx, scale);
 }
 
-Rebel2FontSet::Rebel2FontSet() : numFonts(0), defaultFont(0) {
+Rebel2FontSet::Rebel2FontSet(bool useCJKMode) : numFonts(0), defaultFont(0), useCJK(useCJKMode) {
 	memset(fonts, 0, sizeof(fonts));
 }
 
@@ -694,9 +694,28 @@ static bool parseRebel2FormatCode(const char *str, uint len, uint &pos, int &fon
 	return false;
 }
 
+uint16 decodeRebel2Char(const char *str, uint len, uint &charLen, bool useCJK) {
+	charLen = 0;
+	if (!str || len == 0)
+		return 0;
+
+	const byte lead = (byte)str[0];
+	charLen = 1;
+	if (useCJK && len > 1 && ((lead >= 0x80 && lead <= 0x9f) || (lead >= 0xe0 && lead <= 0xfc))) {
+		charLen = 2;
+		return lead | ((uint16)(byte)str[1] << 8);
+	}
+
+	return lead;
+}
+
 int drawRebel2Char(NutRenderer *font, byte *buffer, Common::Rect &clipRect, int x, int y,
-		int pitch, int16 col, byte chr) {
-	if (!font || chr >= font->getNumChars())
+		int pitch, int16 col, uint16 chr) {
+	if (!font)
+		return 0;
+	if (chr > 0xff)
+		return font->draw2byte(buffer, clipRect, x, y, pitch, col, chr);
+	if (chr >= font->getNumChars())
 		return 0;
 
 	const int charWidth = font->getCharWidth(chr);
@@ -740,13 +759,15 @@ int getRebel2StringWidth(const Rebel2FontSet &fontSet, const char *str, uint len
 		if (parseRebel2FormatCode(str, len, pos, fontId, color))
 			continue;
 
-		const byte chr = (byte)str[pos++];
+		uint charLen;
+		const uint16 chr = decodeRebel2Char(str + pos, len - pos, charLen, fontSet.useCJK);
+		pos += charLen;
 		if (chr == '\n' || chr == '\r')
 			continue;
 
 		NutRenderer *font = fontSet.getFont(fontId);
-		if (font && chr < font->getNumChars())
-			width += font->getCharWidth(chr);
+		if (font && (chr > 0xff || chr < font->getNumChars()))
+			width += font->getCharWidth((byte)chr);
 	}
 
 	return width;
@@ -762,15 +783,17 @@ int getRebel2StringHeight(const Rebel2FontSet &fontSet, const char *str, uint le
 		if (parseRebel2FormatCode(str, len, pos, fontId, color))
 			continue;
 
-		const byte chr = (byte)str[pos++];
+		uint charLen;
+		const uint16 chr = decodeRebel2Char(str + pos, len - pos, charLen, fontSet.useCJK);
+		pos += charLen;
 		if (chr == '\n') {
 			NutRenderer *font = fontSet.getFont(fontId);
 			height += lineHeight ? lineHeight : (font ? font->getFontHeight() : 0);
 			lineHeight = 0;
 		} else if (chr != '\r') {
 			NutRenderer *font = fontSet.getFont(fontId);
-			if (font && chr < font->getNumChars())
-				lineHeight = MAX<int>(lineHeight, font->getCharHeight(chr));
+			if (font && (chr > 0xff || chr < font->getNumChars()))
+				lineHeight = MAX<int>(lineHeight, font->getCharHeight((byte)chr));
 		}
 	}
 
@@ -785,7 +808,9 @@ static void drawRebel2Substring(const Rebel2FontSet &fontSet, const char *str, u
 		if (parseRebel2FormatCode(str, len, pos, fontId, color))
 			continue;
 
-		const byte chr = (byte)str[pos++];
+		uint charLen;
+		const uint16 chr = decodeRebel2Char(str + pos, len - pos, charLen, fontSet.useCJK);
+		pos += charLen;
 		if (chr == '\n' || chr == '\r')
 			continue;
 
diff --git a/engines/scumm/smush/rebel/font_rebel2.h b/engines/scumm/smush/rebel/font_rebel2.h
index 002ba6c38bd..299ff7b0cb7 100644
--- a/engines/scumm/smush/rebel/font_rebel2.h
+++ b/engines/scumm/smush/rebel/font_rebel2.h
@@ -40,8 +40,9 @@ struct Rebel2FontSet {
 	NutRenderer *fonts[kMaxFonts];
 	int numFonts;
 	int defaultFont;
+	bool useCJK;
 
-	Rebel2FontSet();
+	Rebel2FontSet(bool useCJKMode = false);
 	NutRenderer *getFont(int id) const;
 };
 
@@ -51,8 +52,9 @@ bool drawRebel2Codec23Sprite(NutRenderer *sprite, byte *buffer, int pitch, int w
 		const Common::Rect &clipRect, int x, int y, int spriteIdx, int scale);
 bool drawRebel2Codec45Sprite(NutRenderer *sprite, byte *buffer, int pitch, int width, int height,
 		const Common::Rect &clipRect, int x, int y, int spriteIdx, int scale);
+uint16 decodeRebel2Char(const char *str, uint len, uint &charLen, bool useCJK);
 int drawRebel2Char(NutRenderer *font, byte *buffer, Common::Rect &clipRect, int x, int y,
-		int pitch, int16 col, byte chr);
+		int pitch, int16 col, uint16 chr);
 int getRebel2StringWidth(const Rebel2FontSet &fontSet, const char *str, uint len);
 int getRebel2StringHeight(const Rebel2FontSet &fontSet, const char *str, uint len);
 void drawRebel2String(const Rebel2FontSet &fontSet, const char *str, uint len, byte *buffer,
diff --git a/engines/scumm/smush/rebel/smush_multi_font.cpp b/engines/scumm/smush/rebel/smush_multi_font.cpp
index fefa1efb864..8bf8172e884 100644
--- a/engines/scumm/smush/rebel/smush_multi_font.cpp
+++ b/engines/scumm/smush/rebel/smush_multi_font.cpp
@@ -63,7 +63,7 @@ Rebel2FontSet SmushMultiFont::getRebel2FontSet() {
 	const bool highRes = _vm->_screenWidth >= 640 && _vm->_screenHeight >= 400;
 	const char *const *ra2Fonts = highRes ? ra2FontsHi : ra2FontsLo;
 
-	Rebel2FontSet fontSet;
+	Rebel2FontSet fontSet(_vm->_language == Common::JA_JPN);
 	fontSet.numFonts = ARRAYSIZE(ra2FontsLo);
 	fontSet.defaultFont = CLIP<int>(_defaultFont, 0, fontSet.numFonts - 1);
 	for (int i = 0; i < fontSet.numFonts; i++) {


Commit: c02839641b1c42aa63b9f63ee748b006c3e5aeaa
    https://github.com/scummvm/scummvm/commit/c02839641b1c42aa63b9f63ee748b006c3e5aeaa
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: better implementation of the menu for psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/audio.cpp
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/menu.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
index 4f99c1904ea..264ac9425a6 100644
--- a/engines/scumm/insane/rebel2/psx/audio.cpp
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -22,6 +22,7 @@
 #include "audio/decoders/xa.h"
 #include "audio/mixer.h"
 
+#include "common/config-manager.h"
 #include "common/endian.h"
 #include "common/memstream.h"
 #include "common/system.h"
@@ -92,6 +93,9 @@ static bool timeReached(uint32 now, uint32 target) {
 }
 
 static int soundBalance(int pan) {
+	// "sound mode: mono" in the options menu collapses the panning.
+	if (ConfMan.hasKey("rebel2_mono") && ConfMan.getBool("rebel2_mono"))
+		return 0;
 	return CLIP((pan - 64) * 2, -127, 127);
 }
 
diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 729dfd9d5b0..24bef8053b4 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -427,6 +427,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	const bool cursorWasVisible = CursorMan.isVisible();
 	CursorMan.showMouse(false);
 	g_system->warpMouse(160, 120);
+	decoder.setVolume(_settings.videoVolume());
 	decoder.start();
 	const uint32 gameplayStartTime = g_system->getMillis();
 	const Graphics::Surface *background = nullptr;
diff --git a/engines/scumm/insane/rebel2/psx/menu.cpp b/engines/scumm/insane/rebel2/psx/menu.cpp
index 0bbf2d2fbc4..1d720d9cf1e 100644
--- a/engines/scumm/insane/rebel2/psx/menu.cpp
+++ b/engines/scumm/insane/rebel2/psx/menu.cpp
@@ -18,14 +18,14 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include "common/config-manager.h"
 #include "common/events.h"
 #include "common/system.h"
+#include "common/util.h"
 
 #include "graphics/cursorman.h"
 #include "graphics/surface.h"
 
-#include "engines/dialogs.h"
-
 #include "scumm/scumm_v7.h"
 #include "scumm/insane/rebel2/shared.h"
 #include "scumm/insane/rebel2/psx/psx.h"
@@ -33,123 +33,562 @@
 
 namespace Scumm {
 
-static Rebel2MenuCommand getPSXMenuCommand(const Common::Event &event) {
-	if (event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat)
-		return getRebel2MenuCommand(event.kbd);
-	if (event.type != Common::EVENT_CUSTOM_ENGINE_ACTION_START)
-		return kRebel2MenuCommandNone;
-
-	switch (event.customType) {
-	case kScummActionInsaneUp:
-		return kRebel2MenuCommandUp;
-	case kScummActionInsaneDown:
-		return kRebel2MenuCommandDown;
-	case kScummActionInsaneAttack:
-		return kRebel2MenuCommandAccept;
-	case kScummActionInsaneBack:
-	case kScummActionInsaneSkip:
-		return kRebel2MenuCommandCancel;
-	default:
-		return kRebel2MenuCommandNone;
-	}
+// The menus run one logic step per vertical blank.
+static const int kMenuFrameRate = 60;
+
+// Sound effect ids the menus use.
+enum {
+	kMenuSfxMove = 0x1d,
+	kMenuSfxAdjust = 0x1e,
+	kMenuSfxEnter = 0x1f,
+	kMenuSfxLeave = 0x20,
+	kMenuSfxConfirm = 0x40,
+	kMenuSfxStart = 0x44,
+	kMenuSfxDefaults = 0x45
+};
+
+void RA2PSXSettings::reset() {
+	difficulty = 1;
+	sfx = 0x54;
+	music = 0xc00;
+	movies = 0xc00;
+	mono = false;
 }
 
-Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui) {
-	Graphics::Surface surface;
-	surface.create(_vm->_screenWidth, _vm->_screenHeight, g_system->getScreenFormat());
+void RA2PSXSettings::load() {
+	reset();
+	if (ConfMan.hasKey("rebel2_difficulty"))
+		difficulty = CLIP(ConfMan.getInt("rebel2_difficulty"), 0, 2);
+	if (ConfMan.hasKey("rebel2_mono"))
+		mono = ConfMan.getBool("rebel2_mono");
+	if (ConfMan.hasKey("sfx_volume"))
+		sfx = CLIP(ConfMan.getInt("sfx_volume"), 0, 255) * kSFXMaximum / 255 /
+				kSFXStep * kSFXStep;
+	if (ConfMan.hasKey("music_volume"))
+		music = CLIP(ConfMan.getInt("music_volume"), 0, 255) * kCDMaximum / 255 /
+				kCDStep * kCDStep;
+	if (ConfMan.hasKey("speech_volume"))
+		movies = CLIP(ConfMan.getInt("speech_volume"), 0, 255) * kCDMaximum / 255 /
+				kCDStep * kCDStep;
+}
 
-	const bool cursorWasVisible = CursorMan.isVisible();
-	CursorMan.showMouse(true);
-	int selection = 0;
-	MenuResult result = kMenuQuit;
-	bool redraw = true;
+void RA2PSXSettings::save() const {
+	ConfMan.setInt("rebel2_difficulty", difficulty);
+	ConfMan.setBool("rebel2_mono", mono);
+	ConfMan.setInt("sfx_volume", sfx * 255 / kSFXMaximum);
+	ConfMan.setInt("music_volume", MIN<int>(255, music * 255 / kCDMaximum));
+	ConfMan.setInt("speech_volume", MIN<int>(255, movies * 255 / kCDMaximum));
+}
 
-	while (!_vm->shouldQuit()) {
-		bool openGlobalMenu = false;
-		bool openOptions = false;
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			if (event.type == Common::EVENT_QUIT ||
-					event.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				_vm->quitGame();
-				break;
+void RA2PSXSettings::apply(ScummEngine_v7 *vm) const {
+	save();
+	vm->syncSoundSettings();
+}
+
+byte RA2PSXSettings::videoVolume() const {
+	return (byte)CLIP<int>(movies * 255 / kCDMaximum, 0, 255);
+}
+
+#ifdef USE_TINYGL
+
+// A full screen quad in the GPU's subtract mode: level 0 is untouched, 0xff is black.
+struct RA2PSXMenuFade {
+	RA2PSXMenuFade() : level(0), step(0), active(false) {}
+
+	void fadeIn(int amount) {
+		level = 0xff;
+		step = amount;
+		active = true;
+	}
+
+	void fadeOut(int amount) {
+		level = 0;
+		step = -amount;
+		active = true;
+	}
+
+	// Returns true on the step that completes a fade to black.
+	bool update() {
+		if (!active || !step)
+			return false;
+		level -= step;
+		if (step > 0) {
+			if (level <= 0) {
+				level = 0;
+				step = 0;
+				active = false;
 			}
+			return false;
+		}
+		if (level < 0x100)
+			return false;
+		level = 0xff;
+		step = 0;
+		return true;
+	}
+
+	void apply(Graphics::Surface &surface) const {
+		if (active && level > 0)
+			subtractRA2PSXRect(surface, Common::Rect(surface.w, surface.h),
+					level, level, level);
+	}
+
+	int level;
+	int step;
+	bool active;
+};
+
+struct RA2PSXMenuEvents {
+	RA2PSXMenuEvents() : up(false), down(false), left(false), right(false), accept(false),
+			cancel(false), globalMenu(false), mouseMoved(false), mouseClicked(false),
+			mouseX(-1), mouseY(-1) {}
+
+	bool up;
+	bool down;
+	bool left;
+	bool right;
+	bool accept;
+	bool cancel;
+	bool globalMenu;
+	bool mouseMoved;
+	bool mouseClicked;
+	int mouseX;
+	int mouseY;
+};
 
-			if (event.type == Common::EVENT_MAINMENU ||
-					(event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat &&
-					 event.kbd.keycode == Common::KEYCODE_ESCAPE)) {
-				openGlobalMenu = true;
-				continue;
-			}
-
-			if (event.type == Common::EVENT_MOUSEMOVE ||
-					event.type == Common::EVENT_LBUTTONDOWN) {
-				const int xOffset = (surface.w - 320) / 2;
-				const int yOffset = (surface.h - 240) / 2;
-				for (int i = 0; i < 2; ++i) {
-					Common::Rect rect = ui.itemRect(i);
-					rect.translate(xOffset, yOffset);
-					if (!rect.contains(event.mouse.x, event.mouse.y))
-						continue;
-					if (selection != i) {
-						selection = i;
-						redraw = true;
-					}
-					if (event.type == Common::EVENT_LBUTTONDOWN) {
-						if (i == 0)
-							result = kMenuStart;
-						else
-							openOptions = true;
-					}
+static void pollMenuEvents(ScummEngine_v7 *vm, RA2PSXMenuEvents &events) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		switch (event.type) {
+		case Common::EVENT_QUIT:
+		case Common::EVENT_RETURN_TO_LAUNCHER:
+			vm->quitGame();
+			return;
+		case Common::EVENT_MAINMENU:
+			events.globalMenu = true;
+			break;
+		case Common::EVENT_LBUTTONDOWN:
+			events.mouseClicked = true;
+			// fall through
+		case Common::EVENT_MOUSEMOVE:
+			events.mouseMoved = true;
+			events.mouseX = event.mouse.x;
+			events.mouseY = event.mouse.y;
+			break;
+		case Common::EVENT_KEYDOWN:
+			if (event.kbdRepeat)
+				break;
+			switch (event.kbd.keycode) {
+			case Common::KEYCODE_ESCAPE:
+				events.globalMenu = true;
+				break;
+			case Common::KEYCODE_LEFT:
+				events.left = true;
+				break;
+			case Common::KEYCODE_RIGHT:
+				events.right = true;
+				break;
+			case Common::KEYCODE_SPACE:
+				events.accept = true;
+				break;
+			case Common::KEYCODE_BACKSPACE:
+				events.cancel = true;
+				break;
+			default:
+				switch (getRebel2MenuCommand(event.kbd)) {
+				case kRebel2MenuCommandUp:
+					events.up = true;
+					break;
+				case kRebel2MenuCommandDown:
+					events.down = true;
+					break;
+				case kRebel2MenuCommandAccept:
+					events.accept = true;
+					break;
+				default:
 					break;
 				}
+				break;
 			}
-
-			const Rebel2MenuCommand command = getPSXMenuCommand(event);
-			const int oldSelection = selection;
-			const int commandResult = applyRebel2MenuCommand(command, 2, selection);
-			if (selection != oldSelection)
-				redraw = true;
-			if (commandResult == kRebel2MenuResultCancel)
-				openGlobalMenu = true;
-			else if (commandResult == 0)
-				result = kMenuStart;
-			else if (commandResult == 1)
-				openOptions = true;
+			break;
+		case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+			switch (event.customType) {
+			case kScummActionInsaneUp:
+				events.up = true;
+				break;
+			case kScummActionInsaneDown:
+				events.down = true;
+				break;
+			case kScummActionInsaneLeft:
+				events.left = true;
+				break;
+			case kScummActionInsaneRight:
+				events.right = true;
+				break;
+			case kScummActionInsaneAttack:
+				events.accept = true;
+				break;
+			case kScummActionInsaneBack:
+			case kScummActionInsaneSkip:
+				events.cancel = true;
+				break;
+			default:
+				break;
+			}
+			break;
+		default:
+			break;
 		}
+	}
+}
+
+static int hitTestMenu(Common::Rect (*itemRect)(int), int count, const Graphics::Surface &surface,
+		int x, int y) {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	for (int item = 0; item < count; ++item) {
+		Common::Rect rect = itemRect(item);
+		rect.translate(xOffset, yOffset);
+		if (rect.contains(x, y))
+			return item;
+	}
+	return -1;
+}
+
+// The options list and its "adjust sound" page; the other four screens stay dimmed.
+void Rebel2PSX::runOptionsMenu(const RA2PSXOptionsUI &ui, RA2PSXSoundPlayer &sound,
+		RA2PSXTinyGLRenderer &renderer) {
+	enum Screen {
+		kScreenMain,
+		kScreenSound
+	};
+	enum Dialog {
+		kDialogNone,
+		kDialogResetConfirm,
+		kDialogDefaultsRestored
+	};
+
+	Graphics::Surface background;
+	background.create(_vm->_screenWidth, _vm->_screenHeight, g_system->getScreenFormat());
+	background.fillRect(Common::Rect(background.w, background.h), 0);
+
+	Screen screen = kScreenMain;
+	Dialog dialog = kDialogNone;
+	int selection = RA2PSXOptionsUI::kItemDifficulty;
+	int soundSelection = RA2PSXOptionsUI::kSoundItemMode;
+	int confirmSelection = 0;
+	int messageFrames = 0;
+	int cloakAngle = 0x800;
+	int logicFrame = -1;
+	bool leaving = false;
+	RA2PSXMenuFade fade;
+	fade.fadeIn(8);
 
+	// Accumulates across iterations; a press between logic steps must not be lost.
+	RA2PSXMenuEvents events;
+	const uint32 startTime = g_system->getMillis();
+	while (!_vm->shouldQuit()) {
+		pollMenuEvents(_vm, events);
 		if (_vm->shouldQuit())
 			break;
-		if (result == kMenuStart)
+		if (events.globalMenu) {
+			_vm->openMainMenuDialog();
+			_settings.load();
+			events = RA2PSXMenuEvents();
+			continue;
+		}
+
+		const uint32 elapsed = g_system->getMillis() - startTime;
+		const int targetFrame = (int)((uint64)elapsed * kMenuFrameRate / 1000);
+		if (logicFrame >= targetFrame) {
+			g_system->delayMillis(5);
+			continue;
+		}
+
+		bool fadedOut = false;
+		while (logicFrame < targetFrame) {
+			++logicFrame;
+			cloakAngle = (cloakAngle + 6) & 0xfff;
+			fadedOut |= fade.update();
+			if (dialog == kDialogDefaultsRestored && --messageFrames <= 0)
+				dialog = kDialogNone;
+		}
+		if (fadedOut && leaving)
+			break;
+
+		sound.update();
+		if (!fade.active && dialog == kDialogResetConfirm) {
+			if (events.left || events.right || events.up || events.down) {
+				confirmSelection ^= 1;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.cancel) {
+				dialog = kDialogNone;
+				sound.play(kMenuSfxLeave, 0x7f, 0x40);
+			} else if (events.accept) {
+				if (confirmSelection == 1) {
+					_settings.reset();
+					_settings.apply(_vm);
+					selection = RA2PSXOptionsUI::kItemDifficulty;
+					dialog = kDialogDefaultsRestored;
+					messageFrames = 0x78;
+					sound.play(kMenuSfxDefaults, 0x7f, 0x40);
+				} else {
+					dialog = kDialogNone;
+					sound.play(kMenuSfxLeave, 0x7f, 0x40);
+				}
+			}
+		} else if (!fade.active && dialog == kDialogNone && screen == kScreenMain) {
+			const int hit = hitTestMenu(RA2PSXOptionsUI::mainItemRect,
+					RA2PSXOptionsUI::kItemCount, background, events.mouseX, events.mouseY);
+			if (events.mouseMoved && hit >= 0 && hit != selection &&
+					RA2PSXOptionsUI::isItemAvailable(hit)) {
+				selection = hit;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.up || events.down) {
+				const int step = events.down ? 1 : RA2PSXOptionsUI::kItemCount - 1;
+				do {
+					selection = (selection + step) % RA2PSXOptionsUI::kItemCount;
+				} while (!RA2PSXOptionsUI::isItemAvailable(selection));
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (selection == RA2PSXOptionsUI::kItemDifficulty && (events.left || events.right)) {
+				_settings.difficulty = (_settings.difficulty + (events.right ? 1 : 2)) % 3;
+				_settings.save();
+				sound.play(kMenuSfxAdjust, 0x7f, 0x40);
+			}
+			if (events.cancel) {
+				leaving = true;
+				sound.play(kMenuSfxLeave, 0x7f, 0x40);
+				fade.fadeOut(8);
+			} else if (events.accept || (events.mouseClicked && hit == selection)) {
+				switch (selection) {
+				case RA2PSXOptionsUI::kItemAdjustSound:
+					screen = kScreenSound;
+					soundSelection = RA2PSXOptionsUI::kSoundItemMode;
+					sound.play(kMenuSfxEnter, 0x7f, 0x40);
+					break;
+				case RA2PSXOptionsUI::kItemResetSettings:
+					dialog = kDialogResetConfirm;
+					confirmSelection = 0;
+					sound.play(kMenuSfxConfirm, 0x7f, 0x40);
+					break;
+				case RA2PSXOptionsUI::kItemExit:
+					leaving = true;
+					sound.play(kMenuSfxLeave, 0x7f, 0x40);
+					fade.fadeOut(8);
+					break;
+				default:
+					break;
+				}
+			}
+		} else if (!fade.active && dialog == kDialogNone) {
+			const int hit = hitTestMenu(RA2PSXOptionsUI::soundItemRect,
+					RA2PSXOptionsUI::kSoundItemCount, background,
+					events.mouseX, events.mouseY);
+			if (events.mouseMoved && hit >= 0 && hit != soundSelection) {
+				soundSelection = hit;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.up || events.down) {
+				soundSelection = (soundSelection + (events.down ? 1 :
+						RA2PSXOptionsUI::kSoundItemCount - 1)) %
+						RA2PSXOptionsUI::kSoundItemCount;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.left || events.right) {
+				const int direction = events.right ? 1 : -1;
+				bool changed = true;
+				switch (soundSelection) {
+				case RA2PSXOptionsUI::kSoundItemMode:
+					_settings.mono = !_settings.mono;
+					break;
+				case RA2PSXOptionsUI::kSoundItemEffects:
+					_settings.sfx = CLIP<int>(_settings.sfx +
+							direction * RA2PSXSettings::kSFXStep,
+							0, RA2PSXSettings::kSFXMaximum);
+					break;
+				case RA2PSXOptionsUI::kSoundItemMusic:
+					_settings.music = CLIP<int>(_settings.music +
+							direction * RA2PSXSettings::kCDStep,
+							0, RA2PSXSettings::kCDMaximum);
+					break;
+				case RA2PSXOptionsUI::kSoundItemMovies:
+					_settings.movies = CLIP<int>(_settings.movies +
+							direction * RA2PSXSettings::kCDStep,
+							0, RA2PSXSettings::kCDMaximum);
+					break;
+				default:
+					changed = false;
+					break;
+				}
+				if (changed) {
+					_settings.apply(_vm);
+					sound.play(kMenuSfxAdjust, 0x7f, 0x40);
+				}
+			}
+			if (events.cancel || ((events.accept || (events.mouseClicked && hit == soundSelection)) &&
+					soundSelection == RA2PSXOptionsUI::kSoundItemExit)) {
+				screen = kScreenMain;
+				sound.play(kMenuSfxLeave, 0x7f, 0x40);
+			}
+		}
+
+		events = RA2PSXMenuEvents();
+		renderer.beginFrame(background);
+		RA2PSXMatrix transform;
+		transform.setRotationZ(-0x100);
+		transform.preRotateY(cloakAngle);
+		transform.setTranslation(0, 0, 0x604);
+		renderer.renderTransformedModel(_cloakModel, transform, false);
+
+		Graphics::Surface output;
+		renderer.finishFrame(output);
+		if (screen == kScreenMain)
+			ui.drawMain(output, selection, _settings);
+		else
+			ui.drawSound(output, soundSelection, _settings);
+		if (dialog == kDialogResetConfirm)
+			ui.drawDialog(output, "reset settings", "are you sure?", nullptr, confirmSelection);
+		else if (dialog == kDialogDefaultsRestored)
+			ui.drawDialog(output, nullptr, "default values", "restored", -1);
+		fade.apply(output);
+		g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
+		g_system->updateScreen();
+	}
+
+	background.free();
+	_settings.save();
+	ConfMan.flushToDisk();
+}
+
+Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui,
+		const RA2PSXOptionsUI &options) {
+	enum Action {
+		kActionNone,
+		kActionStart,
+		kActionOptions
+	};
+
+	RA2PSXTinyGLRenderer renderer;
+	if (!renderer.init(_vm->_screenWidth, _vm->_screenHeight))
+		return kMenuQuit;
+
+	Graphics::Surface background;
+	background.create(_vm->_screenWidth, _vm->_screenHeight, g_system->getScreenFormat());
+	background.fillRect(Common::Rect(background.w, background.h), 0);
+	ui.drawBackground(background);
+
+	RA2PSXSoundPlayer sound(_vm, _soundBank);
+	const bool cursorWasVisible = CursorMan.isVisible();
+	CursorMan.showMouse(true);
+
+	MenuResult result = kMenuQuit;
+	Action action = kActionNone;
+	int selection = 0;
+	int logoAngle = 0;
+	int logicFrame = -1;
+	int inputDelay = 10;
+	RA2PSXMenuFade fade;
+	fade.fadeIn(0x10);
+	uint32 startTime = g_system->getMillis();
+
+	// Accumulates across iterations; a press between logic steps must not be lost.
+	RA2PSXMenuEvents events;
+	while (!_vm->shouldQuit()) {
+		pollMenuEvents(_vm, events);
+		if (_vm->shouldQuit())
 			break;
-		if (openGlobalMenu) {
+		if (events.globalMenu) {
 			_vm->openMainMenuDialog();
-			redraw = true;
+			_settings.load();
+			events = RA2PSXMenuEvents();
 			continue;
 		}
-		if (openOptions) {
-			GUI::ConfigDialog dialog;
-			dialog.runModal();
-			g_system->applyBackendSettings();
-			_vm->syncSoundSettings();
-			redraw = true;
+
+		const uint32 elapsed = g_system->getMillis() - startTime;
+		const int targetFrame = (int)((uint64)elapsed * kMenuFrameRate / 1000);
+		if (logicFrame >= targetFrame) {
+			g_system->delayMillis(5);
 			continue;
 		}
 
-		if (redraw) {
-			surface.fillRect(Common::Rect(surface.w, surface.h), 0);
-			ui.draw(surface, selection);
-			g_system->copyRectToScreen(surface.getPixels(), surface.pitch,
-					0, 0, surface.w, surface.h);
-			g_system->updateScreen();
-			redraw = false;
+		bool fadedOut = false;
+		while (logicFrame < targetFrame) {
+			++logicFrame;
+			// A flat plate: sweeping edge on to edge on and snapping back reads as a spin.
+			logoAngle = (logoAngle + 0x14) & 0xffff;
+			if (inputDelay)
+				--inputDelay;
+			fadedOut |= fade.update();
 		}
-		g_system->delayMillis(10);
+
+		sound.update();
+		if (fadedOut && action == kActionStart) {
+			result = kMenuStart;
+			break;
+		}
+		if (fadedOut && action == kActionOptions) {
+			runOptionsMenu(options, sound, renderer);
+			if (_vm->shouldQuit())
+				break;
+			action = kActionNone;
+			inputDelay = 0x1e;
+			logicFrame = -1;
+			startTime = g_system->getMillis();
+			fade.fadeIn(0x10);
+		}
+
+		if (action == kActionNone && !inputDelay) {
+			const int hit = hitTestMenu(RA2PSXMainMenuUI::itemRect, 2, background,
+					events.mouseX, events.mouseY);
+			if (events.mouseMoved && hit >= 0 && hit != selection) {
+				selection = hit;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.up || events.down) {
+				selection ^= 1;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+			if (events.accept || (events.mouseClicked && hit == selection)) {
+				action = selection == 0 ? kActionStart : kActionOptions;
+				sound.play(selection == 0 ? kMenuSfxStart : kMenuSfxEnter, 0x7f, 0x40);
+				fade.fadeOut(0x10);
+			}
+		}
+
+		events = RA2PSXMenuEvents();
+		renderer.beginFrame(background);
+		RA2PSXMatrix transform;
+		transform.setScale(0x1800, 0x1800, 0x1000);
+		transform.preRotateY((int16)((logoAngle & 0x7ff) - 0x400));
+		transform.setTranslation(0, 0, 0x604);
+		renderer.renderTransformedModel(_logoModel, transform, false);
+
+		Graphics::Surface output;
+		renderer.finishFrame(output);
+		ui.drawForeground(output, selection);
+		fade.apply(output);
+		g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
+		g_system->updateScreen();
 	}
 
-	surface.free();
+	sound.stopAll();
+	background.free();
 	CursorMan.showMouse(cursorWasVisible);
 	return _vm->shouldQuit() ? kMenuQuit : result;
 }
 
+#else
+
+Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui,
+		const RA2PSXOptionsUI &options) {
+	(void)ui;
+	(void)options;
+	return kMenuQuit;
+}
+
+#endif
+
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index 1ff211f14b6..a88d3af4bb9 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -35,6 +35,95 @@ static bool readU32(const Common::Array<byte> &data, uint32 offset, uint32 &valu
 	return true;
 }
 
+// One turn is 4096 PlayStation angle units.
+static const float kRA2PSXAngleScale = 0.0015339807878856412f;
+
+void RA2PSXMatrix::setIdentity() {
+	for (uint row = 0; row < 3; ++row) {
+		for (uint column = 0; column < 3; ++column)
+			rotation[row][column] = row == column ? 1.0f : 0.0f;
+		translation[row] = 0.0f;
+	}
+}
+
+void RA2PSXMatrix::setScale(int x, int y, int z) {
+	setIdentity();
+	rotation[0][0] = x / 4096.0f;
+	rotation[1][1] = y / 4096.0f;
+	rotation[2][2] = z / 4096.0f;
+}
+
+void RA2PSXMatrix::setRotationX(int angle) {
+	const float cosine = cosf(angle * kRA2PSXAngleScale);
+	const float sine = sinf(angle * kRA2PSXAngleScale);
+	setIdentity();
+	rotation[1][1] = cosine;
+	rotation[1][2] = sine;
+	rotation[2][1] = -sine;
+	rotation[2][2] = cosine;
+}
+
+void RA2PSXMatrix::setRotationY(int angle) {
+	const float cosine = cosf(angle * kRA2PSXAngleScale);
+	const float sine = sinf(angle * kRA2PSXAngleScale);
+	setIdentity();
+	rotation[0][0] = cosine;
+	rotation[0][2] = -sine;
+	rotation[2][0] = sine;
+	rotation[2][2] = cosine;
+}
+
+void RA2PSXMatrix::setRotationZ(int angle) {
+	const float cosine = cosf(angle * kRA2PSXAngleScale);
+	const float sine = sinf(angle * kRA2PSXAngleScale);
+	setIdentity();
+	rotation[0][0] = cosine;
+	rotation[0][1] = sine;
+	rotation[1][0] = -sine;
+	rotation[1][1] = cosine;
+}
+
+static void preMultiply(RA2PSXMatrix &matrix, const RA2PSXMatrix &left) {
+	RA2PSXMatrix result;
+	for (uint row = 0; row < 3; ++row) {
+		for (uint column = 0; column < 3; ++column) {
+			float value = 0.0f;
+			for (uint i = 0; i < 3; ++i)
+				value += left.rotation[row][i] * matrix.rotation[i][column];
+			result.rotation[row][column] = value;
+		}
+		float value = left.translation[row];
+		for (uint i = 0; i < 3; ++i)
+			value += left.rotation[row][i] * matrix.translation[i];
+		result.translation[row] = value;
+	}
+	matrix = result;
+}
+
+void RA2PSXMatrix::preRotateX(int angle) {
+	RA2PSXMatrix rotate;
+	rotate.setRotationX(angle);
+	preMultiply(*this, rotate);
+}
+
+void RA2PSXMatrix::preRotateY(int angle) {
+	RA2PSXMatrix rotate;
+	rotate.setRotationY(angle);
+	preMultiply(*this, rotate);
+}
+
+void RA2PSXMatrix::preRotateZ(int angle) {
+	RA2PSXMatrix rotate;
+	rotate.setRotationZ(angle);
+	preMultiply(*this, rotate);
+}
+
+void RA2PSXMatrix::setTranslation(int x, int y, int z) {
+	translation[0] = (float)x;
+	translation[1] = (float)y;
+	translation[2] = (float)z;
+}
+
 bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		Common::Array<RA2PSXTexture> &textures) {
 	const uint initialCount = textures.size();
@@ -586,6 +675,27 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 	const float modelYy = downY * cosine - rightY * sine;
 	const float modelYz = downZ * cosine - rightZ * sine;
 
+	RA2PSXMatrix transform;
+	transform.rotation[0][0] = modelXx;
+	transform.rotation[0][1] = modelYx;
+	transform.rotation[0][2] = forwardX;
+	transform.rotation[1][0] = modelXy;
+	transform.rotation[1][1] = modelYy;
+	transform.rotation[1][2] = forwardY;
+	transform.rotation[2][0] = modelXz;
+	transform.rotation[2][1] = modelYz;
+	transform.rotation[2][2] = forwardZ;
+	transform.translation[0] = x;
+	transform.translation[1] = y;
+	transform.translation[2] = z;
+	renderTransformedModel(model, transform, depthTest);
+}
+
+void RA2PSXTinyGLRenderer::renderTransformedModel(const RA2PSXModel &model,
+		const RA2PSXMatrix &transform, bool depthTest) {
+	if (!_context || model.vertices().empty())
+		return;
+
 	struct ProjectedVertex {
 		float x;
 		float y;
@@ -599,9 +709,12 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 	const float focalLength = _width * 2.0f;
 	for (uint i = 0; i < model.vertices().size(); ++i) {
 		const RA2PSXVertex &vertex = model.vertices()[i];
-		const float worldX = x + modelXx * vertex.x + modelYx * vertex.y + forwardX * vertex.z;
-		const float worldY = y + modelXy * vertex.x + modelYy * vertex.y + forwardY * vertex.z;
-		const float worldZ = z + modelXz * vertex.x + modelYz * vertex.y + forwardZ * vertex.z;
+		const float worldX = transform.translation[0] + transform.rotation[0][0] * vertex.x +
+				transform.rotation[0][1] * vertex.y + transform.rotation[0][2] * vertex.z;
+		const float worldY = transform.translation[1] + transform.rotation[1][0] * vertex.x +
+				transform.rotation[1][1] * vertex.y + transform.rotation[1][2] * vertex.z;
+		const float worldZ = transform.translation[2] + transform.rotation[2][0] * vertex.x +
+				transform.rotation[2][1] * vertex.y + transform.rotation[2][2] * vertex.z;
 		projected[i].z = worldZ;
 		projected[i].visible = worldZ > 1.0f;
 		if (projected[i].visible) {
@@ -641,7 +754,8 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 	for (uint faceIndex = 0; faceIndex < projectedFaces.size(); ++faceIndex) {
 		const ProjectedFace &projectedFace = projectedFaces[faceIndex];
 		const RA2PSXFace &face = faces[projectedFace.index];
-		const float orderingDepth = (z - projectedFace.depth) * 512.0f / model.radius();
+		const float orderingDepth = (transform.translation[2] - projectedFace.depth) *
+				512.0f / model.radius();
 
 		const RA2PSXTexture *texture = model.texture(face.texture);
 		setFaceState(model, face);
@@ -649,15 +763,15 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 		tglBegin(face.vertexCount == 4 ? TGL_QUAD_STRIP : TGL_TRIANGLES);
 		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
 			setFaceColor(face, vertexIndex,
-					modelXx * face.normalX[vertexIndex] +
-							modelYx * face.normalY[vertexIndex] +
-							forwardX * face.normalZ[vertexIndex],
-					modelXy * face.normalX[vertexIndex] +
-							modelYy * face.normalY[vertexIndex] +
-							forwardY * face.normalZ[vertexIndex],
-					modelXz * face.normalX[vertexIndex] +
-							modelYz * face.normalY[vertexIndex] +
-							forwardZ * face.normalZ[vertexIndex], faceDepth);
+					transform.rotation[0][0] * face.normalX[vertexIndex] +
+							transform.rotation[0][1] * face.normalY[vertexIndex] +
+							transform.rotation[0][2] * face.normalZ[vertexIndex],
+					transform.rotation[1][0] * face.normalX[vertexIndex] +
+							transform.rotation[1][1] * face.normalY[vertexIndex] +
+							transform.rotation[1][2] * face.normalZ[vertexIndex],
+					transform.rotation[2][0] * face.normalX[vertexIndex] +
+							transform.rotation[2][1] * face.normalY[vertexIndex] +
+							transform.rotation[2][2] * face.normalZ[vertexIndex], faceDepth);
 			if (texture)
 				tglTexCoord2f((face.u[vertexIndex] + 0.5f) / texture->width,
 						(face.v[vertexIndex] + 0.5f) / texture->height);
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 36265c007d2..185530431f8 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -152,6 +152,7 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 		decoder.close();
 		return false;
 	}
+	decoder.setVolume(_settings.videoVolume());
 	decoder.start();
 	const bool cursorWasVisible = CursorMan.isVisible();
 	CursorMan.showMouse(false);
@@ -223,10 +224,22 @@ bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
 	RA2PSXArchive archive;
 	const bool loaded = archive.load(*stream);
 	delete stream;
+	if (!loaded || !menu.load(archive))
+		return false;
+
+	Common::Array<byte> textureData;
+	Common::Array<byte> logoData;
+	Common::Array<byte> cloakData;
+	if (!archive.getMember("menuTex", textureData) ||
+			!archive.getMember("fOFS/Logo", logoData) || !_logoModel.load(logoData) ||
+			!_logoModel.loadTextures(textureData) ||
+			!archive.getMember("fOFS/Cloak", cloakData) || !_cloakModel.load(cloakData) ||
+			!_cloakModel.loadTextures(textureData))
+		return false;
+
 	Common::Array<byte> soundData;
 	Common::Array<byte> soundProjectData;
-	return loaded && menu.load(archive) &&
-			archive.getMember("SNDsmp", soundData) &&
+	return archive.getMember("SNDsmp", soundData) &&
 			archive.getMember("sNDdata", soundProjectData) &&
 			_soundBank.load(soundData, soundProjectData);
 }
@@ -266,9 +279,11 @@ Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
 	RA2PSXMainMenuUI menu;
 	RA2PSXMovieText movieText;
+	_settings.load();
 	if (!loadGlobalAssets(menu))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation menu resources"));
+	const RA2PSXOptionsUI options(menu.textures());
 	if (!loadMovieTextAssets(movieText))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation movie fonts"));
@@ -279,7 +294,7 @@ Common::Error Rebel2PSX::runGame() {
 				_("Could not play the PlayStation introduction"));
 	}
 
-	const MenuResult menuResult = runMainMenu(menu);
+	const MenuResult menuResult = runMainMenu(menu, options);
 	if (menuResult == kMenuQuit)
 		return Common::kNoError;
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 3f09bb80398..3fc44a29645 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -39,6 +39,7 @@ class ScummEngine_v7;
 class RA2PSXLevel1UI;
 class RA2PSXMainMenuUI;
 class RA2PSXMovieText;
+class RA2PSXOptionsUI;
 
 enum RA2PSXMovieTextSequence {
 	kRA2PSXMovieTextNone,
@@ -132,12 +133,55 @@ private:
 	Impl *_impl;
 };
 
+// Options menu settings; sfx steps by 7 up to 0x70, the CD volumes by 0x200 up to 0x1000.
+struct RA2PSXSettings {
+	enum {
+		kSFXMaximum = 0x70,
+		kSFXStep = 7,
+		kCDMaximum = 0x1000,
+		kCDStep = 0x200
+	};
+
+	RA2PSXSettings() { reset(); }
+
+	void reset();
+	void load();
+	void save() const;
+	void apply(ScummEngine_v7 *vm) const;
+	byte videoVolume() const;
+
+	int difficulty;
+	int sfx;
+	int music;
+	int movies;
+	bool mono;
+};
+
 struct RA2PSXVertex {
 	int16 x;
 	int16 y;
 	int16 z;
 };
 
+// PlayStation matrix helpers: 4096 angle units per turn, 1/4096 fixed point scales,
+// and pre-rotating multiplies from the left.
+struct RA2PSXMatrix {
+	RA2PSXMatrix() { setIdentity(); }
+
+	void setIdentity();
+	void setScale(int x, int y, int z);
+	void setRotationX(int angle);
+	void setRotationY(int angle);
+	void setRotationZ(int angle);
+	void preRotateX(int angle);
+	void preRotateY(int angle);
+	void preRotateZ(int angle);
+	void setTranslation(int x, int y, int z);
+
+	float rotation[3][3];
+	float translation[3];
+};
+
 struct RA2PSXTexture {
 	Common::String name;
 	uint16 width;
@@ -197,6 +241,8 @@ public:
 	void renderPerspectiveModel(const RA2PSXModel &model, float x, float y, float z,
 			float directionX, float directionY, float directionZ, float roll,
 			bool depthTest = true);
+	void renderTransformedModel(const RA2PSXModel &model, const RA2PSXMatrix &transform,
+			bool depthTest = true);
 	void finishFrame(Graphics::Surface &surface);
 
 private:
@@ -248,7 +294,12 @@ private:
 			const RA2PSXMovieText *movieText = nullptr,
 			RA2PSXMovieTextSequence textSequence = kRA2PSXMovieTextNone);
 	bool playIntroSequence(const RA2PSXMovieText &movieText);
-	MenuResult runMainMenu(const RA2PSXMainMenuUI &ui);
+	MenuResult runMainMenu(const RA2PSXMainMenuUI &ui, const RA2PSXOptionsUI &options);
+#ifdef USE_TINYGL
+	// TinyGL tracks one context, so the options screen borrows the title screen's renderer.
+	void runOptionsMenu(const RA2PSXOptionsUI &ui, RA2PSXSoundPlayer &sound,
+			RA2PSXTinyGLRenderer &renderer);
+#endif
 	bool loadGlobalAssets(RA2PSXMainMenuUI &menu);
 	bool loadMovieTextAssets(RA2PSXMovieText &movieText);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
@@ -259,6 +310,10 @@ private:
 
 	ScummEngine_v7 *_vm;
 	RA2PSXSoundBank _soundBank;
+	RA2PSXSettings _settings;
+	// The crest on the title screen and the freighter behind the options list.
+	RA2PSXModel _logoModel;
+	RA2PSXModel _cloakModel;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index bc25de6c95b..1b8f87062fd 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -124,48 +124,122 @@ void RA2PSXTextureSet::draw(Graphics::Surface &surface, const char *name,
 			g = MIN(g, 0xff);
 			b = MIN(b, 0xff);
 			const int destX = x + sourceX - sourceLeft;
-			if (blend == kBlendAdditive) {
+			if (blend != kBlendOpaque) {
 				byte destR, destG, destB;
 				surface.format.colorToRGB(surface.getPixel(destX, destY), destR, destG, destB);
-				r = MIN<int>(0xff, r + destR);
-				g = MIN<int>(0xff, g + destG);
-				b = MIN<int>(0xff, b + destB);
+				if (blend == kBlendAdditive) {
+					r = MIN<int>(0xff, r + destR);
+					g = MIN<int>(0xff, g + destG);
+					b = MIN<int>(0xff, b + destB);
+				} else {
+					r = (r + destR) / 2;
+					g = (g + destG) / 2;
+					b = (b + destB) / 2;
+				}
 			}
 			surface.setPixel(destX, destY, surface.format.RGBToColor(r, g, b));
 		}
 	}
 }
 
-void RA2PSXTextureSet::drawText(Graphics::Surface &surface, const char *font,
-		const char *text, int x, int y) const {
-	static const char glyphs[] = "abcdefghijklmnopqrstuvwxyz0123456789%-:.,+/C ";
-	static const byte widths[] = {
-		6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 8, 6, 6, 6,
-		6, 6, 6, 6, 6, 6, 8, 6, 7, 6, 6, 4, 6, 6, 6, 6,
-		6, 6, 6, 6, 6, 6, 2, 2, 2, 6, 6, 8, 2
-	};
-	static_assert(ARRAYSIZE(glyphs) == ARRAYSIZE(widths) + 1,
-			"RA2 PSX glyph widths do not match the font map");
+void RA2PSXTextureSet::drawShape(Graphics::Surface &surface, const char *name,
+		int x, int y, const Common::Rect &source, byte r, byte g, byte b) const {
+	const RA2PSXTexture *texture = find(name);
+	if (!texture)
+		return;
 
-	for (; *text; ++text) {
-		int glyph = -1;
-		for (uint i = 0; i < ARRAYSIZE(widths); ++i) {
-			if (*text == glyphs[i]) {
-				glyph = i;
-				break;
-			}
+	const int sourceLeft = MAX<int>(0, source.left);
+	const int sourceTop = MAX<int>(0, source.top);
+	const int sourceRight = MIN<int>(texture->width, source.right);
+	const int sourceBottom = MIN<int>(texture->height, source.bottom);
+	const uint32 color = surface.format.RGBToColor(r, g, b);
+	for (int sourceY = sourceTop; sourceY < sourceBottom; ++sourceY) {
+		const int destY = y + sourceY - source.top;
+		if (destY < 0 || destY >= surface.h)
+			continue;
+		for (int sourceX = sourceLeft; sourceX < sourceRight; ++sourceX) {
+			const int destX = x + sourceX - source.left;
+			if (destX < 0 || destX >= surface.w)
+				continue;
+			if (texture->pixels[sourceY * texture->width + sourceX] & 0x01000000)
+				surface.setPixel(destX, destY, color);
 		}
+	}
+}
+
+void subtractRA2PSXRect(Graphics::Surface &surface, const Common::Rect &rect,
+		int r, int g, int b) {
+	const int left = MAX<int>(0, rect.left);
+	const int top = MAX<int>(0, rect.top);
+	const int right = MIN<int>(surface.w, rect.right);
+	const int bottom = MIN<int>(surface.h, rect.bottom);
+	for (int y = top; y < bottom; ++y) {
+		for (int x = left; x < right; ++x) {
+			byte destR, destG, destB;
+			surface.format.colorToRGB(surface.getPixel(x, y), destR, destG, destB);
+			surface.setPixel(x, y, surface.format.RGBToColor(MAX<int>(0, destR - r),
+					MAX<int>(0, destG - g), MAX<int>(0, destB - b)));
+		}
+	}
+}
+
+void drawRA2PSXGouraudLine(Graphics::Surface &surface, int x0, int y0, int x1, int y1,
+		const byte *from, const byte *to) {
+	const int steps = MAX(ABS(x1 - x0), ABS(y1 - y0));
+	for (int step = 0; step <= steps; ++step) {
+		const int x = steps ? x0 + (x1 - x0) * step / steps : x0;
+		const int y = steps ? y0 + (y1 - y0) * step / steps : y0;
+		if (x < 0 || x >= surface.w || y < 0 || y >= surface.h)
+			continue;
+		const int r = steps ? from[0] + (to[0] - from[0]) * step / steps : from[0];
+		const int g = steps ? from[1] + (to[1] - from[1]) * step / steps : from[1];
+		const int b = steps ? from[2] + (to[2] - from[2]) * step / steps : from[2];
+		surface.setPixel(x, y, surface.format.RGBToColor(r, g, b));
+	}
+}
+
+static const char kSmallGlyphs[] = "abcdefghijklmnopqrstuvwxyz0123456789%-:.?+/C ";
+static const byte kSmallWidths[] = {
+	6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 8, 6, 6, 6,
+	6, 6, 6, 6, 6, 6, 8, 6, 7, 6, 6, 4, 6, 6, 6, 6,
+	6, 6, 6, 6, 6, 6, 2, 2, 6, 6, 6, 8, 2
+};
+static_assert(ARRAYSIZE(kSmallGlyphs) == ARRAYSIZE(kSmallWidths) + 1,
+		"RA2 PSX glyph widths do not match the font map");
+
+static int findSmallGlyph(char character) {
+	for (uint i = 0; i < ARRAYSIZE(kSmallWidths); ++i) {
+		if (character == kSmallGlyphs[i])
+			return i;
+	}
+	return -1;
+}
+
+void RA2PSXTextureSet::drawText(Graphics::Surface &surface, const char *font,
+		const char *text, int x, int y, int brightness) const {
+	for (; *text; ++text) {
+		const int glyph = findSmallGlyph(*text);
 		if (glyph < 0)
 			continue;
 
 		const int sourceX = (glyph % 12) * 8;
 		const int sourceY = (glyph / 12) * 8;
 		draw(surface, font, x, y, Common::Rect(sourceX, sourceY,
-				sourceX + widths[glyph], sourceY + 8));
-		x += widths[glyph] + 2;
+				sourceX + kSmallWidths[glyph], sourceY + 8), brightness);
+		x += kSmallWidths[glyph] + 2;
 	}
 }
 
+int RA2PSXTextureSet::measureText(const char *text) const {
+	int width = 0;
+	for (; *text; ++text) {
+		const int glyph = findSmallGlyph(*text);
+		if (glyph >= 0)
+			width += kSmallWidths[glyph] + 2;
+	}
+	return width ? width - 2 : 0;
+}
+
 struct RA2PSXMovieFont {
 	const char *texture;
 	const char *characters;
@@ -350,6 +424,24 @@ static int measureMovieText(const RA2PSXMovieFont &font, const char *text,
 	return width ? width - spacing : 0;
 }
 
+int RA2PSXTextureSet::measureHeadline(const char *text) const {
+	return measureMovieText(kMovieBigFont, text, 0xffff, 2);
+}
+
+void RA2PSXTextureSet::drawHeadline(Graphics::Surface &surface, const char *text,
+		int x, int y) const {
+	for (; *text; ++text) {
+		RA2PSXMovieGlyph glyph;
+		if (!findMovieGlyph(kMovieBigFont, *text, glyph))
+			continue;
+		const Common::Rect source(glyph.x, glyph.row * kMovieBigFont.rowStep,
+				glyph.x + glyph.width, glyph.row * kMovieBigFont.rowStep + kMovieBigFont.height);
+		drawShape(surface, "FNT24BIG", x + 1, y + 1, source, 0, 0, 0);
+		draw(surface, "FNT24BIG", x, y, source);
+		x += glyph.width + 2;
+	}
+}
+
 static int scaleMovieX(int x) {
 	const int remainder = x % 3;
 	if (remainder)
@@ -445,20 +537,37 @@ void RA2PSXMovieText::draw(Graphics::Surface &surface,
 }
 
 bool RA2PSXMainMenuUI::load(const RA2PSXArchive &archive) {
+	static const char *const required[] = {
+		"BACK_L", "BACK_R", "TITLE", "FNT24BIG", "VOLUMES",
+		"STD_FT2", "STD_FT3", "STD_FT4", "STD_FT5", "STD_FT6"
+	};
+
 	Common::Array<byte> data;
 	_textures.clear();
-	return archive.getMember("menuTex", data) && _textures.append(data) &&
-			_textures.has("BACK_L") && _textures.has("BACK_R") &&
-			_textures.has("TITLE") && _textures.has("STD_FT2") &&
-			_textures.has("STD_FT4") && _textures.has("STD_FT6");
+	if (!archive.getMember("menuTex", data) || !_textures.append(data))
+		return false;
+	for (uint i = 0; i < ARRAYSIZE(required); ++i) {
+		if (!_textures.has(required[i]))
+			return false;
+	}
+	return true;
+}
+
+void RA2PSXMainMenuUI::drawBackground(Graphics::Surface &surface) const {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	// The original dims both halves of the backdrop to 0x60/0x80.
+	_textures.draw(surface, "BACK_L", xOffset, yOffset, Common::Rect(0, 0, 224, 240), 0x60);
+	_textures.draw(surface, "BACK_R", xOffset + 224, yOffset, Common::Rect(0, 0, 96, 240), 0x60);
 }
 
-void RA2PSXMainMenuUI::draw(Graphics::Surface &surface, int selection) const {
+void RA2PSXMainMenuUI::drawForeground(Graphics::Surface &surface, int selection) const {
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2;
-	_textures.draw(surface, "BACK_L", xOffset, yOffset, Common::Rect(0, 0, 224, 240));
-	_textures.draw(surface, "BACK_R", xOffset + 224, yOffset, Common::Rect(0, 0, 96, 240));
-	_textures.draw(surface, "TITLE", xOffset + 72, yOffset + 22, Common::Rect(0, 0, 176, 124));
+	subtractRA2PSXRect(surface, Common::Rect(xOffset + 127, yOffset + 161,
+			xOffset + 193, yOffset + 191), 0x46, 0x46, 0x46);
+	_textures.draw(surface, "TITLE", xOffset + 72, yOffset + 22, Common::Rect(0, 0, 176, 124),
+			0x80, RA2PSXTextureSet::kBlendAverage);
 
 	static const char *const items[] = { "start", "options" };
 	static const int itemX[] = { 141, 134 };
@@ -472,10 +581,199 @@ void RA2PSXMainMenuUI::draw(Graphics::Surface &surface, int selection) const {
 			xOffset + 16, yOffset + 212);
 }
 
-Common::Rect RA2PSXMainMenuUI::itemRect(int item) const {
+Common::Rect RA2PSXMainMenuUI::itemRect(int item) {
 	return Common::Rect(120, 164 + item * 10, 200, 174 + item * 10);
 }
 
+// Row positions from the original widget tables.
+static const int16 kOptionRowY[] = { 70, 85, 100, 115, 130, 145, 160, 190 };
+static const int16 kSoundRowY[] = { 87, 102, 117, 132, 162 };
+
+Common::Rect RA2PSXOptionsUI::mainItemRect(int item) {
+	const int y = kOptionRowY[CLIP<int>(item, 0, ARRAYSIZE(kOptionRowY) - 1)];
+	return Common::Rect(90, y - 1, 230, y + 9);
+}
+
+Common::Rect RA2PSXOptionsUI::soundItemRect(int item) {
+	const int y = kSoundRowY[CLIP<int>(item, 0, ARRAYSIZE(kSoundRowY) - 1)];
+	return Common::Rect(90, y - 1, 230, y + 9);
+}
+
+enum {
+	kOptionStateNormal,
+	kOptionStateValue,
+	kOptionStateSelected,
+	// Ours, not the original's: the plain colour dimmed.
+	kOptionStateUnavailable
+};
+
+bool RA2PSXOptionsUI::isItemAvailable(int item) {
+	switch (item) {
+	case kItemDifficulty:
+	case kItemAdjustSound:
+	case kItemResetSettings:
+	case kItemExit:
+		return true;
+	default:
+		// These four still need their own screens.
+		return false;
+	}
+}
+
+void RA2PSXOptionsUI::drawItem(Graphics::Surface &surface, const char *text, int x, int y,
+		bool centered, int state) const {
+	static const char *const fonts[] = { "STD_FT3", "STD_FT2", "STD_FT4", "STD_FT3" };
+	static const int brightness[] = { 100, 0x80, 0x80, 0x30 };
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	if (centered)
+		x = (320 - _textures.measureText(text)) / 2;
+	_textures.drawText(surface, fonts[state], text, xOffset + x, yOffset + y,
+			brightness[state]);
+}
+
+void RA2PSXOptionsUI::drawMain(Graphics::Surface &surface, int selection,
+		const RA2PSXSettings &settings) const {
+	struct Entry {
+		const char *text;
+		int16 x;
+		bool centered;
+	};
+	static const Entry entries[kItemCount] = {
+		{ "difficulty:",    96, false },
+		{ "adjust sound",   70, true },
+		{ "enter passcode", 70, true },
+		{ "memory card",    70, true },
+		{ "high scores",    70, true },
+		{ "controls",       70, true },
+		{ "reset settings", 70, true },
+		{ "exit",           70, true }
+	};
+	static const Entry difficulties[] = {
+		{ "easy",   182, false },
+		{ "medium", 176, false },
+		{ "hard",   182, false }
+	};
+
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	subtractRA2PSXRect(surface, Common::Rect(xOffset + 90, yOffset + 63,
+			xOffset + 230, yOffset + 203), 0x20, 0x20, 0x20);
+	_textures.drawHeadline(surface, "Options",
+			xOffset + (320 - _textures.measureHeadline("Options")) / 2, yOffset + 36);
+
+	for (int item = 0; item < kItemCount; ++item) {
+		int state = kOptionStateNormal;
+		if (item == selection)
+			state = kOptionStateSelected;
+		else if (!isItemAvailable(item))
+			state = kOptionStateUnavailable;
+		drawItem(surface, entries[item].text, entries[item].x, kOptionRowY[item],
+				entries[item].centered, state);
+	}
+
+	// The value dims while the difficulty row itself is highlighted.
+	const Entry &value = difficulties[CLIP<int>(settings.difficulty, 0, 2)];
+	drawItem(surface, value.text, value.x, kOptionRowY[kItemDifficulty], false,
+			selection == kItemDifficulty ? kOptionStateValue : kOptionStateNormal);
+}
+
+void RA2PSXOptionsUI::drawVolume(Graphics::Surface &surface, int y, int level) const {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	const int filled = CLIP(level, 0, 8) * 4;
+	if (filled)
+		_textures.draw(surface, "VOLUMES", xOffset + 180, yOffset + y,
+				Common::Rect(0, 0, filled, 8), 0x60);
+	if (filled < 32)
+		_textures.draw(surface, "VOLUMES", xOffset + 180 + filled, yOffset + y,
+				Common::Rect(filled, 8, 32, 16), 0x32);
+}
+
+void RA2PSXOptionsUI::drawSound(Graphics::Surface &surface, int selection,
+		const RA2PSXSettings &settings) const {
+	static const char *const labels[kSoundItemCount] = {
+		"sound mode:", "game f/x:", "game music:", "movies:", "exit"
+	};
+	static const int16 labelX[kSoundItemCount] = { 94, 110, 96, 126, 0 };
+
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	subtractRA2PSXRect(surface, Common::Rect(xOffset + 90, yOffset + 83,
+			xOffset + 230, yOffset + 173), 0x20, 0x20, 0x20);
+	_textures.drawHeadline(surface, "Adjust Sound",
+			xOffset + (320 - _textures.measureHeadline("Adjust Sound")) / 2, yOffset + 37);
+
+	for (int item = 0; item < kSoundItemCount; ++item) {
+		drawItem(surface, labels[item], labelX[item], kSoundRowY[item],
+				item == kSoundItemExit, item == selection ?
+						kOptionStateSelected : kOptionStateNormal);
+	}
+
+	drawItem(surface, settings.mono ? "mono" : "stereo", settings.mono ? 186 : 179, 87,
+			false, selection == kSoundItemMode ? kOptionStateValue : kOptionStateNormal);
+	drawVolume(surface, 102, (settings.sfx + 1) / 14);
+	drawVolume(surface, 117, settings.music / RA2PSXSettings::kCDStep);
+	drawVolume(surface, 132, settings.movies / RA2PSXSettings::kCDStep);
+}
+
+void RA2PSXOptionsUI::drawDialog(Graphics::Surface &surface, const char *headline,
+		const char *question, const char *detail, int selection) const {
+	static const byte kBright[3] = { 0xfc, 0xc8, 0x19 };
+	static const byte kDark[3] = { 0x80, 0x65, 0x0c };
+	static const byte kMessageBright[3] = { 0xff, 0xff, 0xff };
+	static const byte kMessageDark[3] = { 0x80, 0x80, 0x80 };
+
+	const bool message = headline == nullptr;
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	const int height = message ? 0x20 : 0x40;
+	int width = MAX(_textures.measureText(question), detail ? _textures.measureText(detail) : 0);
+	if (!message)
+		width = MAX(width, _textures.measureText(headline));
+	const int left = (320 - width) / 2;
+	const int top = (240 - height) / 2;
+
+	const Common::Rect box(xOffset + left - 6, yOffset + top - 4,
+			xOffset + left - 6 + width + 12, yOffset + top - 4 + height + 8);
+	subtractRA2PSXRect(surface, box, 0xff, 0xff, 0xff);
+
+	const byte *bright = message ? kMessageBright : kBright;
+	const byte *dark = message ? kMessageDark : kDark;
+	const int midX = box.left + (width + 12) / 2;
+	const int midY = box.top + (height + 8) / 2;
+	drawRA2PSXGouraudLine(surface, box.left, box.top, midX, box.top, bright, dark);
+	drawRA2PSXGouraudLine(surface, midX, box.top, box.right, box.top, dark, bright);
+	drawRA2PSXGouraudLine(surface, box.right, box.top, box.right, midY, bright, dark);
+	drawRA2PSXGouraudLine(surface, box.right, midY, box.right, box.bottom, dark, bright);
+	drawRA2PSXGouraudLine(surface, box.right, box.bottom, midX, box.bottom, bright, dark);
+	drawRA2PSXGouraudLine(surface, midX, box.bottom, box.left, box.bottom, dark, bright);
+	drawRA2PSXGouraudLine(surface, box.left, box.bottom, box.left, midY, bright, dark);
+	drawRA2PSXGouraudLine(surface, box.left, midY, box.left, box.top, dark, bright);
+
+	const char *const bodyFont = message ? "STD_FT3" : "STD_FT2";
+	const int questionY = message ? (detail ? 9 : 13) : (detail ? 0x14 : 0x19);
+	if (!message)
+		_textures.drawText(surface, "STD_FT5", headline,
+				xOffset + left + (width - _textures.measureText(headline)) / 2,
+				yOffset + top, 0x80);
+	_textures.drawText(surface, bodyFont, question,
+			xOffset + left + (width - _textures.measureText(question)) / 2,
+			yOffset + top + questionY, 0x80);
+	if (detail)
+		_textures.drawText(surface, bodyFont, detail,
+				xOffset + left + (width - _textures.measureText(detail)) / 2,
+				yOffset + top + (message ? 0x13 : 0x1e), 0x80);
+
+	if (selection >= 0) {
+		_textures.drawText(surface, selection == 1 ? "STD_FT4" : "STD_FT3", "yes",
+				xOffset + left + 10, yOffset + top + 0x32, selection == 1 ? 0x80 : 100);
+		_textures.drawText(surface, selection == 0 ? "STD_FT4" : "STD_FT3", "no",
+				xOffset + left + width - 0x1e, yOffset + top + 0x32,
+				selection == 0 ? 0x80 : 100);
+	}
+}
+
 bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
 	Common::Array<byte> data;
 	_textures.clear();
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 04d23f629b7..43993e20f73 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -25,7 +25,9 @@ class RA2PSXTextureSet {
 public:
 	enum BlendMode {
 		kBlendOpaque,
-		kBlendAdditive
+		kBlendAdditive,
+		// The GPU's semi-transparency mode 0: half background, half source.
+		kBlendAverage
 	};
 
 	void clear() { _textures.clear(); }
@@ -37,13 +39,25 @@ public:
 			const Common::Rect &source, int brightness = 0x80,
 			BlendMode blend = kBlendOpaque) const;
 	void drawText(Graphics::Surface &surface, const char *font, const char *text,
-			int x, int y) const;
+			int x, int y, int brightness = 0x80) const;
+	int measureText(const char *text) const;
+	// The 16 pixel tall menu headline font, drawn over a black drop shadow.
+	void drawHeadline(Graphics::Surface &surface, const char *text, int x, int y) const;
+	int measureHeadline(const char *text) const;
 
 private:
 	const RA2PSXTexture *find(const char *name) const;
+	void drawShape(Graphics::Surface &surface, const char *name, int x, int y,
+			const Common::Rect &source, byte r, byte g, byte b) const;
 	Common::Array<RA2PSXTexture> _textures;
 };
 
+// The GPU's semi-transparency mode 2: background minus source.
+void subtractRA2PSXRect(Graphics::Surface &surface, const Common::Rect &rect,
+		int r, int g, int b);
+void drawRA2PSXGouraudLine(Graphics::Surface &surface, int x0, int y0, int x1, int y1,
+		const byte *from, const byte *to);
+
 class RA2PSXMovieText {
 public:
 	bool load(Common::SeekableReadStream &executable);
@@ -57,13 +71,62 @@ private:
 class RA2PSXMainMenuUI {
 public:
 	bool load(const RA2PSXArchive &archive);
-	void draw(Graphics::Surface &surface, int selection) const;
-	Common::Rect itemRect(int item) const;
+	// The spinning crest is drawn between these two passes.
+	void drawBackground(Graphics::Surface &surface) const;
+	void drawForeground(Graphics::Surface &surface, int selection) const;
+	static Common::Rect itemRect(int item);
+	const RA2PSXTextureSet &textures() const { return _textures; }
 
 private:
 	RA2PSXTextureSet _textures;
 };
 
+// Options menu; passcode, memory card, high scores and controls are dimmed for now.
+class RA2PSXOptionsUI {
+public:
+	enum Item {
+		kItemDifficulty,
+		kItemAdjustSound,
+		kItemPasscode,
+		kItemMemoryCard,
+		kItemHighScores,
+		kItemControls,
+		kItemResetSettings,
+		kItemExit,
+		kItemCount
+	};
+
+	enum SoundItem {
+		kSoundItemMode,
+		kSoundItemEffects,
+		kSoundItemMusic,
+		kSoundItemMovies,
+		kSoundItemExit,
+		kSoundItemCount
+	};
+
+	explicit RA2PSXOptionsUI(const RA2PSXTextureSet &textures) : _textures(textures) {}
+
+	static bool isItemAvailable(int item);
+	static Common::Rect mainItemRect(int item);
+	static Common::Rect soundItemRect(int item);
+
+	void drawMain(Graphics::Surface &surface, int selection,
+			const RA2PSXSettings &settings) const;
+	void drawSound(Graphics::Surface &surface, int selection,
+			const RA2PSXSettings &settings) const;
+	// A two or three line box; pass a negative selection for a plain message.
+	void drawDialog(Graphics::Surface &surface, const char *headline,
+			const char *question, const char *detail, int selection) const;
+
+private:
+	void drawItem(Graphics::Surface &surface, const char *text, int x, int y,
+			bool centered, int state) const;
+	void drawVolume(Graphics::Surface &surface, int y, int level) const;
+
+	const RA2PSXTextureSet &_textures;
+};
+
 class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);


Commit: 8d355b9ef7b2beeaf45aa9de2f517d19016ac152
    https://github.com/scummvm/scummvm/commit/8d355b9ef7b2beeaf45aa9de2f517d19016ac152
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: implementation of the level select menu for psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/menu.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/menu.cpp b/engines/scumm/insane/rebel2/psx/menu.cpp
index 1d720d9cf1e..15bc1601ffa 100644
--- a/engines/scumm/insane/rebel2/psx/menu.cpp
+++ b/engines/scumm/insane/rebel2/psx/menu.cpp
@@ -30,6 +30,7 @@
 #include "scumm/insane/rebel2/shared.h"
 #include "scumm/insane/rebel2/psx/psx.h"
 #include "scumm/insane/rebel2/psx/ui.h"
+#include "scumm/insane/rebel2/psx/video.h"
 
 namespace Scumm {
 
@@ -43,6 +44,7 @@ enum {
 	kMenuSfxEnter = 0x1f,
 	kMenuSfxLeave = 0x20,
 	kMenuSfxConfirm = 0x40,
+	kMenuSfxLocked = 0x21,
 	kMenuSfxStart = 0x44,
 	kMenuSfxDefaults = 0x45
 };
@@ -53,6 +55,9 @@ void RA2PSXSettings::reset() {
 	music = 0xc00;
 	movies = 0xc00;
 	mono = false;
+	for (int i = 0; i < ARRAYSIZE(unlocked); ++i)
+		unlocked[i] = 1;
+	unlockAll = false;
 }
 
 void RA2PSXSettings::load() {
@@ -70,6 +75,12 @@ void RA2PSXSettings::load() {
 	if (ConfMan.hasKey("speech_volume"))
 		movies = CLIP(ConfMan.getInt("speech_volume"), 0, 255) * kCDMaximum / 255 /
 				kCDStep * kCDStep;
+	unlockAll = ConfMan.getBool("rebel2_unlock_all");
+	for (int i = 0; i < ARRAYSIZE(unlocked); ++i) {
+		const Common::String key = Common::String::format("rebel2_psx_chapters%d", i);
+		if (ConfMan.hasKey(key))
+			unlocked[i] = CLIP(ConfMan.getInt(key), 1, 16);
+	}
 }
 
 void RA2PSXSettings::save() const {
@@ -78,6 +89,8 @@ void RA2PSXSettings::save() const {
 	ConfMan.setInt("sfx_volume", sfx * 255 / kSFXMaximum);
 	ConfMan.setInt("music_volume", MIN<int>(255, music * 255 / kCDMaximum));
 	ConfMan.setInt("speech_volume", MIN<int>(255, movies * 255 / kCDMaximum));
+	for (int i = 0; i < ARRAYSIZE(unlocked); ++i)
+		ConfMan.setInt(Common::String::format("rebel2_psx_chapters%d", i), unlocked[i]);
 }
 
 void RA2PSXSettings::apply(ScummEngine_v7 *vm) const {
@@ -85,6 +98,10 @@ void RA2PSXSettings::apply(ScummEngine_v7 *vm) const {
 	vm->syncSoundSettings();
 }
 
+int RA2PSXSettings::unlockedChapters() const {
+	return unlockAll ? 16 : CLIP<int>(unlocked[CLIP(difficulty, 0, 2)], 1, 16);
+}
+
 byte RA2PSXSettings::videoVolume() const {
 	return (byte)CLIP<int>(movies * 255 / kCDMaximum, 0, 255);
 }
@@ -463,6 +480,198 @@ void Rebel2PSX::runOptionsMenu(const RA2PSXOptionsUI &ui, RA2PSXSoundPlayer &sou
 	ConfMan.flushToDisk();
 }
 
+int Rebel2PSX::runChapterSelect(const RA2PSXChapterSelectUI &ui) {
+	RA2PSXTinyGLRenderer renderer;
+	if (!renderer.init(_vm->_screenWidth, _vm->_screenHeight))
+		return 0;
+
+	// The tile previews are frames of LEVELSEL.STR, looped for as long as the
+	// screen is up; without them the tiles stay empty.
+	Common::SeekableReadStream *stream = openRawFile("LEVELSEL.STR", 1);
+	RA2PSXStreamDecoder decoder(RA2PSXStreamDecoder::kVersion2);
+	bool previewsReady = stream && decoder.loadStream(stream) &&
+			decoder.setOutputPixelFormat(g_system->getScreenFormat());
+	if (previewsReady) {
+		decoder.setVolume(_settings.videoVolume());
+		decoder.start();
+	} else {
+		decoder.close();
+	}
+
+	Graphics::Surface background;
+	background.create(_vm->_screenWidth, _vm->_screenHeight, g_system->getScreenFormat());
+	background.fillRect(Common::Rect(background.w, background.h), 0);
+
+	RA2PSXSoundPlayer sound(_vm, _soundBank);
+	const bool cursorWasVisible = CursorMan.isVisible();
+	CursorMan.showMouse(true);
+
+	const int unlocked = _settings.unlockedChapters();
+	// Kept in our own surface so it survives restarting the stream.
+	Graphics::Surface previews;
+	bool havePreviews = false;
+	int selection = 0;
+	int chosen = 0;
+	int scroll = 0;
+	int target = 0;
+	int velocity = 0;
+	int direction = 1;
+	int cloakAngle = 0x800;
+	int crestAngle = 0;
+	int logicFrame = -1;
+	bool leaving = false;
+	RA2PSXMenuFade fade;
+	fade.fadeIn(8);
+
+	// Accumulates across iterations; a press between logic steps must not be lost.
+	RA2PSXMenuEvents events;
+	const uint32 startTime = g_system->getMillis();
+	while (!_vm->shouldQuit()) {
+		pollMenuEvents(_vm, events);
+		if (_vm->shouldQuit())
+			break;
+		if (events.globalMenu) {
+			_vm->openMainMenuDialog();
+			events = RA2PSXMenuEvents();
+			continue;
+		}
+
+		if (previewsReady) {
+			while (decoder.needsUpdate()) {
+				const Graphics::Surface *frame = decoder.decodeNextFrame();
+				if (!frame || frame->w < 320 || frame->h < 240)
+					break;
+				if (!previews.getPixels())
+					previews.create(320, 240, frame->format);
+				previews.copyRectToSurface(*frame, 0, 0, Common::Rect(320, 240));
+				havePreviews = true;
+			}
+			// The clip only runs for about twelve seconds, so start it over.
+			if (decoder.endOfVideo()) {
+				decoder.close();
+				stream = openRawFile("LEVELSEL.STR", 1);
+				previewsReady = stream && decoder.loadStream(stream) &&
+						decoder.setOutputPixelFormat(g_system->getScreenFormat());
+				if (previewsReady) {
+					decoder.setVolume(_settings.videoVolume());
+					decoder.start();
+				} else {
+					decoder.close();
+				}
+			}
+		}
+
+		const uint32 elapsed = g_system->getMillis() - startTime;
+		const int targetFrame = (int)((uint64)elapsed * kMenuFrameRate / 1000);
+		if (logicFrame >= targetFrame) {
+			g_system->delayMillis(5);
+			continue;
+		}
+
+		bool fadedOut = false;
+		while (logicFrame < targetFrame) {
+			++logicFrame;
+			cloakAngle = (cloakAngle + 6) & 0xfff;
+			crestAngle = (crestAngle + 0x14) & 0xfff;
+			fadedOut |= fade.update();
+
+			// The list eases towards the selected row and never overshoots.
+			if (scroll != target) {
+				scroll += (MIN(velocity, 0x8000) >> 12) * direction;
+				if (direction > 0 ? scroll >= target : scroll <= target) {
+					scroll = target;
+					velocity = 0;
+				}
+			}
+			if (velocity) {
+				velocity = velocity * 0xe80 >> 12;
+				if (velocity < 0x1000)
+					velocity = 0x1000;
+			}
+		}
+		if (fadedOut && leaving)
+			break;
+
+		sound.update();
+		if (!fade.active) {
+			int mouseHit = -1;
+			if (events.mouseMoved) {
+				const int xOffset = (background.w - 320) / 2;
+				const int yOffset = (background.h - 240) / 2;
+				for (int chapter = 0; chapter < RA2PSXChapterSelectUI::kChapterCount; ++chapter) {
+					Common::Rect rect = RA2PSXChapterSelectUI::tileRect(chapter, scroll);
+					rect.translate(xOffset, yOffset);
+					if (rect.contains(events.mouseX, events.mouseY)) {
+						mouseHit = chapter;
+						break;
+					}
+				}
+			}
+
+			int step = 0;
+			if (events.down && selection < RA2PSXChapterSelectUI::kChapterCount - 1)
+				step = 1;
+			else if (events.up && selection > 0)
+				step = -1;
+			else if (mouseHit >= 0 && mouseHit != selection)
+				step = mouseHit > selection ? 1 : -1;
+			if (step) {
+				const int rows = mouseHit >= 0 && !events.up && !events.down ?
+						ABS(mouseHit - selection) : 1;
+				selection += step * rows;
+				target += step * rows * RA2PSXChapterSelectUI::kRowPitch;
+				direction = step;
+				velocity += 0x8000;
+				sound.play(kMenuSfxMove, 0x7f, 0x40);
+			}
+
+			const bool activate = events.accept ||
+					(events.mouseClicked && mouseHit == selection);
+			if (events.cancel) {
+				leaving = true;
+				sound.play(kMenuSfxLeave, 0x7f, 0x40);
+				fade.fadeOut(8);
+			} else if (activate) {
+				if (selection >= unlocked) {
+					// The original just refuses a locked chapter with a buzz.
+					sound.play(kMenuSfxLocked, 0x7f, 0x40);
+				} else {
+					chosen = selection + 1;
+					leaving = true;
+					sound.play(kMenuSfxStart, 0x7f, 0x40);
+					fade.fadeOut(8);
+				}
+			}
+		}
+
+		events = RA2PSXMenuEvents();
+		renderer.beginFrame(background);
+		RA2PSXMatrix transform;
+		transform.setRotationZ(-0x100);
+		transform.preRotateY(cloakAngle);
+		transform.setTranslation(0, 0, 0x604);
+		renderer.renderTransformedModel(_cloakModel, transform, false);
+		transform.setScale(0x6f5, 0x6f5, 0x6f5);
+		transform.preRotateY(crestAngle);
+		transform.setTranslation(-0x120, -0xd6, 0x604);
+		renderer.renderTransformedModel(_crestModel, transform, false);
+
+		Graphics::Surface output;
+		renderer.finishFrame(output);
+		ui.draw(output, havePreviews ? &previews : nullptr, scroll, selection, unlocked);
+		fade.apply(output);
+		g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
+		g_system->updateScreen();
+	}
+
+	sound.stopAll();
+	decoder.close();
+	previews.free();
+	background.free();
+	CursorMan.showMouse(cursorWasVisible);
+	return _vm->shouldQuit() ? 0 : chosen;
+}
+
 Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui,
 		const RA2PSXOptionsUI &options) {
 	enum Action {
@@ -589,6 +798,11 @@ Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui,
 	return kMenuQuit;
 }
 
+int Rebel2PSX::runChapterSelect(const RA2PSXChapterSelectUI &ui) {
+	(void)ui;
+	return 0;
+}
+
 #endif
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 185530431f8..be7f11be0ff 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -230,11 +230,13 @@ bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
 	Common::Array<byte> textureData;
 	Common::Array<byte> logoData;
 	Common::Array<byte> cloakData;
+	Common::Array<byte> crestData;
 	if (!archive.getMember("menuTex", textureData) ||
 			!archive.getMember("fOFS/Logo", logoData) || !_logoModel.load(logoData) ||
 			!_logoModel.loadTextures(textureData) ||
 			!archive.getMember("fOFS/Cloak", cloakData) || !_cloakModel.load(cloakData) ||
-			!_cloakModel.loadTextures(textureData))
+			!_cloakModel.loadTextures(textureData) ||
+			!archive.getMember("fOFS/LogoDbl", crestData) || !_crestModel.load(crestData))
 		return false;
 
 	Common::Array<byte> soundData;
@@ -284,6 +286,7 @@ Common::Error Rebel2PSX::runGame() {
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation menu resources"));
 	const RA2PSXOptionsUI options(menu.textures());
+	const RA2PSXChapterSelectUI chapters(menu.textures());
 	if (!loadMovieTextAssets(movieText))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation movie fonts"));
@@ -294,8 +297,25 @@ Common::Error Rebel2PSX::runGame() {
 				_("Could not play the PlayStation introduction"));
 	}
 
-	const MenuResult menuResult = runMainMenu(menu, options);
-	if (menuResult == kMenuQuit)
+	int chapter = 0;
+	while (!chapter && !_vm->shouldQuit()) {
+		if (runMainMenu(menu, options) == kMenuQuit)
+			return Common::kNoError;
+
+		bool backedOut = false;
+		while (!chapter && !backedOut && !_vm->shouldQuit()) {
+			const int picked = runChapterSelect(chapters);
+			if (!picked)
+				backedOut = true;
+			else if (picked > 1)
+				// Only chapter 1 is ported; stay on the list rather than
+				// pretending to start the rest.
+				warning("Rebel Assault II: chapter %d is not implemented yet", picked);
+			else
+				chapter = picked;
+		}
+	}
+	if (_vm->shouldQuit())
 		return Common::kNoError;
 
 	RA2PSXModel enemy;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 3fc44a29645..24f003d10af 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -40,6 +40,7 @@ class RA2PSXLevel1UI;
 class RA2PSXMainMenuUI;
 class RA2PSXMovieText;
 class RA2PSXOptionsUI;
+class RA2PSXChapterSelectUI;
 
 enum RA2PSXMovieTextSequence {
 	kRA2PSXMovieTextNone,
@@ -149,12 +150,16 @@ struct RA2PSXSettings {
 	void save() const;
 	void apply(ScummEngine_v7 *vm) const;
 	byte videoVolume() const;
+	// The original keeps a per-difficulty count of the chapters reached.
+	int unlockedChapters() const;
 
 	int difficulty;
 	int sfx;
 	int music;
 	int movies;
 	bool mono;
+	int unlocked[3];
+	bool unlockAll;
 };
 
 struct RA2PSXVertex {
@@ -300,6 +305,8 @@ private:
 	void runOptionsMenu(const RA2PSXOptionsUI &ui, RA2PSXSoundPlayer &sound,
 			RA2PSXTinyGLRenderer &renderer);
 #endif
+	// Returns the chosen chapter, 1 to 16, or 0 when the player backs out.
+	int runChapterSelect(const RA2PSXChapterSelectUI &ui);
 	bool loadGlobalAssets(RA2PSXMainMenuUI &menu);
 	bool loadMovieTextAssets(RA2PSXMovieText &movieText);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
@@ -311,9 +318,11 @@ private:
 	ScummEngine_v7 *_vm;
 	RA2PSXSoundBank _soundBank;
 	RA2PSXSettings _settings;
-	// The crest on the title screen and the freighter behind the options list.
+	// The crest on the title screen, the freighter behind the options list and
+	// the double sided crest on the chapter select.
 	RA2PSXModel _logoModel;
 	RA2PSXModel _cloakModel;
+	RA2PSXModel _crestModel;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index 1b8f87062fd..e22b4617596 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -198,6 +198,13 @@ void drawRA2PSXGouraudLine(Graphics::Surface &surface, int x0, int y0, int x1, i
 	}
 }
 
+void drawRA2PSXMenuHints(Graphics::Surface &surface, const RA2PSXTextureSet &textures) {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	textures.draw(surface, "BACK", xOffset + 20, yOffset + 216, Common::Rect(0, 0, 46, 11));
+	textures.draw(surface, "SELECT", xOffset + 252, yOffset + 216, Common::Rect(0, 0, 56, 11));
+}
+
 static const char kSmallGlyphs[] = "abcdefghijklmnopqrstuvwxyz0123456789%-:.?+/C ";
 static const byte kSmallWidths[] = {
 	6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 8, 6, 6, 6,
@@ -538,7 +545,7 @@ void RA2PSXMovieText::draw(Graphics::Surface &surface,
 
 bool RA2PSXMainMenuUI::load(const RA2PSXArchive &archive) {
 	static const char *const required[] = {
-		"BACK_L", "BACK_R", "TITLE", "FNT24BIG", "VOLUMES",
+		"BACK_L", "BACK_R", "TITLE", "FNT24BIG", "VOLUMES", "BACK", "SELECT",
 		"STD_FT2", "STD_FT3", "STD_FT4", "STD_FT5", "STD_FT6"
 	};
 
@@ -672,6 +679,8 @@ void RA2PSXOptionsUI::drawMain(Graphics::Surface &surface, int selection,
 				entries[item].centered, state);
 	}
 
+	drawRA2PSXMenuHints(surface, _textures);
+
 	// The value dims while the difficulty row itself is highlighted.
 	const Entry &value = difficulties[CLIP<int>(settings.difficulty, 0, 2)];
 	drawItem(surface, value.text, value.x, kOptionRowY[kItemDifficulty], false,
@@ -712,6 +721,7 @@ void RA2PSXOptionsUI::drawSound(Graphics::Surface &surface, int selection,
 
 	drawItem(surface, settings.mono ? "mono" : "stereo", settings.mono ? 186 : 179, 87,
 			false, selection == kSoundItemMode ? kOptionStateValue : kOptionStateNormal);
+	drawRA2PSXMenuHints(surface, _textures);
 	drawVolume(surface, 102, (settings.sfx + 1) / 14);
 	drawVolume(surface, 117, settings.music / RA2PSXSettings::kCDStep);
 	drawVolume(surface, 132, settings.movies / RA2PSXSettings::kCDStep);
@@ -774,6 +784,137 @@ void RA2PSXOptionsUI::drawDialog(Graphics::Surface &surface, const char *headlin
 	}
 }
 
+// Row positions and label offsets from the original chapter table.
+struct RA2PSXChapterEntry {
+	const char *title;
+	int16 titleX;
+	const char *name;
+	int16 nameX;
+	int16 barWidth;
+};
+
+static const RA2PSXChapterEntry kChapters[RA2PSXChapterSelectUI::kChapterCount] = {
+	{ "chapter 1:",  142, "the dreighton triangle",       52, 158 },
+	{ "chapter 2:",  110, "the corellia star",           110, 122 },
+	{ "chapter 3:",  140, "mining tunnels",              110, 100 },
+	{ "chapter 4:",  110, "the mine field",              110,  96 },
+	{ "chapter 5:",  140, "interceptor attack",           76, 134 },
+	{ "chapter 6:",  110, "the mining facility",         110, 128 },
+	{ "chapter 7:",  140, "tie training",                132,  78 },
+	{ "chapter 8:",  110, "flight to imdaar",            110, 112 },
+	{ "chapter 9:",  140, "the asteroid field",           84, 126 },
+	{ "chapter 10:", 110, "speeder bikes",               110,  94 },
+	{ "chapter 11:", 136, "aboard the terror",            84, 126 },
+	{ "chapter 12:", 110, "the sewer",                   110,  76 },
+	{ "chapter 13:", 134, "escaping the star destroyer",  12, 198 },
+	{ "chapter 14:", 110, "tie attack",                  110,  78 },
+	{ "chapter 15:", 134, "imdaar alpha",                122,  88 },
+	{ "finale:",     110, "the return home",             110, 112 }
+};
+
+int RA2PSXChapterSelectUI::rowY(int chapter, int scroll) {
+	return 91 - scroll + chapter * kRowPitch;
+}
+
+Common::Rect RA2PSXChapterSelectUI::tileRect(int chapter, int scroll) {
+	// Even chapters put the tile on the right, odd ones on the left.
+	const int x = (chapter & 1) ? 20 : 220;
+	const int y = rowY(chapter, scroll);
+	return Common::Rect(x, y, x + kTileWidth, y + kTileHeight);
+}
+
+void RA2PSXChapterSelectUI::drawTile(Graphics::Surface &surface, int chapter, int y,
+		const Graphics::Surface *previews, bool selected, bool unlocked) const {
+	static const byte kIdleCorner[3] = { 0x80, 0x80, 0x80 };
+	static const byte kIdleEdge[3] = { 0xff, 0xff, 0xff };
+	// The original cycles the highlight through an animated CLUT; this uses the
+	// same accent the menu dialogs are drawn with.
+	static const byte kActiveCorner[3] = { 0xfc, 0xc8, 0x19 };
+	static const byte kActiveEdge[3] = { 0x80, 0x65, 0x0c };
+
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	const int left = xOffset + ((chapter & 1) ? 20 : 220);
+	const int top = yOffset + y;
+
+	if (unlocked && previews && previews->w >= 320 && previews->h >= 240) {
+		const int sourceX = (chapter % 4) * kTileWidth;
+		const int sourceY = (chapter / 4) * kTileHeight;
+		Common::Rect source(sourceX, sourceY, sourceX + kTileWidth, sourceY + kTileHeight);
+		int destX = left;
+		int destY = top;
+		if (destY < 0) {
+			source.top -= destY;
+			destY = 0;
+		}
+		source.bottom = MIN<int>(source.bottom, source.top + surface.h - destY);
+		if (source.top < source.bottom && destX >= 0 && destX + kTileWidth <= surface.w)
+			surface.copyRectToSurface(*previews, destX, destY, source);
+	}
+
+	const byte *corner = selected ? kActiveCorner : kIdleCorner;
+	const byte *edge = selected ? kActiveEdge : kIdleEdge;
+	const int midX = left + kTileWidth / 2;
+	const int midY = top + kTileHeight / 2;
+	const int right = left + kTileWidth;
+	const int bottom = top + kTileHeight;
+	drawRA2PSXGouraudLine(surface, left, top, midX, top, corner, edge);
+	drawRA2PSXGouraudLine(surface, midX, top, right, top, edge, corner);
+	drawRA2PSXGouraudLine(surface, right, top, right, midY, corner, edge);
+	drawRA2PSXGouraudLine(surface, right, midY, right, bottom, edge, corner);
+	drawRA2PSXGouraudLine(surface, right, bottom, midX, bottom, corner, edge);
+	drawRA2PSXGouraudLine(surface, midX, bottom, left, bottom, edge, corner);
+	drawRA2PSXGouraudLine(surface, left, bottom, left, midY, corner, edge);
+	drawRA2PSXGouraudLine(surface, left, midY, left, top, edge, corner);
+}
+
+void RA2PSXChapterSelectUI::drawLabel(Graphics::Surface &surface, int chapter, int y,
+		bool selected, bool unlocked) const {
+	const RA2PSXChapterEntry &entry = kChapters[chapter];
+	const char *const font = selected ? "STD_FT4" : "STD_FT2";
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+
+	if (!unlocked) {
+		// Locked chapters only get their number, without the episode name.
+		Common::String text = chapter == kChapterCount - 1 ?
+				Common::String("finale") : Common::String::format("chapter %d", chapter + 1);
+		const int width = _textures.measureText(text.c_str());
+		const int x = (chapter & 1) ? 110 : 210 - width;
+		if (selected)
+			subtractRA2PSXRect(surface, Common::Rect(xOffset + ((chapter & 1) ? 106 : 206 - width),
+					yOffset + y + 40, xOffset + ((chapter & 1) ? 106 : 206 - width) + width + 8,
+					yOffset + y + 54), 0x28, 0x28, 0x28);
+		_textures.drawText(surface, font, text.c_str(), xOffset + x, yOffset + y + 26, 0x7f);
+		return;
+	}
+
+	if (selected)
+		subtractRA2PSXRect(surface, Common::Rect(xOffset + ((chapter & 1) ? 106 : 206 - entry.barWidth),
+				yOffset + y + 17, xOffset + ((chapter & 1) ? 106 : 206 - entry.barWidth) +
+				entry.barWidth + 8, yOffset + y + 44), 0x28, 0x28, 0x28);
+	_textures.drawText(surface, font, entry.title, xOffset + entry.titleX, yOffset + y + 20, 0x7f);
+	_textures.drawText(surface, font, entry.name, xOffset + entry.nameX, yOffset + y + 33, 0x7f);
+}
+
+void RA2PSXChapterSelectUI::draw(Graphics::Surface &surface, const Graphics::Surface *previews,
+		int scroll, int selection, int unlocked) const {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+	for (int chapter = 0; chapter < kChapterCount; ++chapter) {
+		const int y = rowY(chapter, scroll);
+		if (y < -kRowPitch || y > 240)
+			continue;
+		const bool selected = chapter == selection;
+		const bool open = chapter < unlocked;
+		drawTile(surface, chapter, y, previews, selected, open);
+		drawLabel(surface, chapter, y, selected, open);
+	}
+	// The headline and the button captions sit over the rows.
+	_textures.drawHeadline(surface, "Select Chapter", xOffset + 110, yOffset + 20);
+	drawRA2PSXMenuHints(surface, _textures);
+}
+
 bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
 	Common::Array<byte> data;
 	_textures.clear();
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 43993e20f73..3733a144acc 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -57,6 +57,8 @@ void subtractRA2PSXRect(Graphics::Surface &surface, const Common::Rect &rect,
 		int r, int g, int b);
 void drawRA2PSXGouraudLine(Graphics::Surface &surface, int x0, int y0, int x1, int y1,
 		const byte *from, const byte *to);
+// The "back" and "select" button captions along the bottom of every menu page.
+void drawRA2PSXMenuHints(Graphics::Surface &surface, const RA2PSXTextureSet &textures);
 
 class RA2PSXMovieText {
 public:
@@ -127,6 +129,33 @@ private:
 	const RA2PSXTextureSet &_textures;
 };
 
+// The scrolling chapter grid, whose tiles are frames of LEVELSEL.STR diced into
+// a four by four atlas of 80x60 previews.
+class RA2PSXChapterSelectUI {
+public:
+	enum {
+		kChapterCount = 16,
+		kTileWidth = 80,
+		kTileHeight = 60,
+		kRowPitch = 62
+	};
+
+	explicit RA2PSXChapterSelectUI(const RA2PSXTextureSet &textures) : _textures(textures) {}
+
+	void draw(Graphics::Surface &surface, const Graphics::Surface *previews,
+			int scroll, int selection, int unlocked) const;
+	static int rowY(int chapter, int scroll);
+	static Common::Rect tileRect(int chapter, int scroll);
+
+private:
+	void drawTile(Graphics::Surface &surface, int chapter, int y,
+			const Graphics::Surface *previews, bool selected, bool unlocked) const;
+	void drawLabel(Graphics::Surface &surface, int chapter, int y,
+			bool selected, bool unlocked) const;
+
+	const RA2PSXTextureSet &_textures;
+};
+
 class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);


Commit: e3d7ec975185750671401ad817a9c2f43a46627d
    https://github.com/scummvm/scummvm/commit/e3d7ec975185750671401ad817a9c2f43a46627d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: use correct sound for L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/audio.cpp
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/psx.h


diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
index 264ac9425a6..6d1bb0fb137 100644
--- a/engines/scumm/insane/rebel2/psx/audio.cpp
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -25,12 +25,15 @@
 #include "common/config-manager.h"
 #include "common/endian.h"
 #include "common/memstream.h"
+#include "common/ptr.h"
 #include "common/system.h"
 #include "common/util.h"
 
 #include "scumm/scumm_v7.h"
 #include "scumm/insane/rebel2/psx/psx.h"
 
+#include <math.h>
+
 namespace Scumm {
 
 class RA2PSXADSRStream : public Audio::RewindableAudioStream {
@@ -84,6 +87,82 @@ private:
 	uint32 _sustain;
 };
 
+// A 14 bit bend centred on kNeutralBend, scaled into semitones by the range
+// macro command 0x33 sets.
+static int bendToStep(int bend, int rangeUp, int rangeDown) {
+	const int offset = CLIP(bend, 0, 0x3fff) - RA2PSXSoundPlayer::kNeutralBend;
+	if (!offset)
+		return 0x1000;
+	const int range = offset < 0 ? rangeDown : rangeUp;
+	const double semitones = (double)offset * range / 8192.0;
+	return CLIP((int)(0x1000 * pow(2.0, semitones / 12.0)), 1, 0x8000);
+}
+
+// The SPU bends in place; the mixer cannot, so the step lives in a block the
+// voice keeps a reference to and the stream reads.
+struct RA2PSXPitch {
+	RA2PSXPitch(int initial) : pitch(initial) {}
+	int pitch;
+};
+
+typedef Common::SharedPtr<RA2PSXPitch> RA2PSXPitchRef;
+
+class RA2PSXPitchStream : public Audio::RewindableAudioStream {
+public:
+	RA2PSXPitchStream(Audio::RewindableAudioStream *stream, const RA2PSXPitchRef &pitch)
+			: _stream(stream), _pitch(pitch), _channels(stream->isStereo() ? 2 : 1),
+			_fraction(0x1000), _ended(false) {
+		for (int i = 0; i < 2; ++i)
+			_previous[i] = _current[i] = 0;
+	}
+
+	~RA2PSXPitchStream() override { delete _stream; }
+
+	int readBuffer(int16 *buffer, const int numSamples) override {
+		int produced = 0;
+		while (produced + _channels <= numSamples) {
+			while (_fraction >= 0x1000) {
+				int16 frame[2] = { 0, 0 };
+				if (_stream->readBuffer(frame, _channels) != _channels) {
+					_ended = true;
+					return produced;
+				}
+				for (int i = 0; i < _channels; ++i) {
+					_previous[i] = _current[i];
+					_current[i] = frame[i];
+				}
+				_fraction -= 0x1000;
+			}
+			for (int i = 0; i < _channels; ++i)
+				buffer[produced++] = (int16)((_previous[i] * (0x1000 - _fraction) +
+						_current[i] * _fraction) >> 12);
+			_fraction += CLIP(_pitch->pitch, 1, 0x3fff);
+		}
+		return produced;
+	}
+
+	bool isStereo() const override { return _channels == 2; }
+	bool endOfData() const override { return _ended || _stream->endOfData(); }
+	int getRate() const override { return _stream->getRate(); }
+
+	bool rewind() override {
+		_fraction = 0x1000;
+		_ended = false;
+		for (int i = 0; i < 2; ++i)
+			_previous[i] = _current[i] = 0;
+		return _stream->rewind();
+	}
+
+private:
+	Audio::RewindableAudioStream *_stream;
+	RA2PSXPitchRef _pitch;
+	int _channels;
+	int _fraction;
+	bool _ended;
+	int16 _previous[2];
+	int16 _current[2];
+};
+
 static bool matchesTag(const Common::Array<byte> &data, uint32 offset, const char *tag) {
 	return offset + 4 <= data.size() && !memcmp(data.data() + offset, tag, 4);
 }
@@ -148,14 +227,17 @@ bool RA2PSXSoundBank::load(const Common::Array<byte> &sampleData,
 	if (macroBase64 < 4 || macroBase64 > projectData.size())
 		return false;
 	const uint32 macroBase = (uint32)macroBase64;
-	for (uint32 record = macroBase - 4; record + 8 <= projectData.size() &&
+	// Records are (int32 offset relative to the table, uint32 id) pairs; swapping
+	// the two pairs every macro with the previous entry's id.
+	for (uint32 record = macroBase; record + 8 <= projectData.size() &&
 			macros.size() < 256; record += 8) {
-		const int32 relative = (int32)READ_LE_UINT32(projectData.data() + record + 4);
+		const int32 relative = (int32)READ_LE_UINT32(projectData.data() + record);
+		const uint32 id = READ_LE_UINT32(projectData.data() + record + 4);
 		const int64 target = (int64)macroBase + relative;
-		if (target < 0 || target + 8 > (int64)projectData.size() || (target & 3))
+		if (id > 0xffff || target < 0 || target + 8 > (int64)projectData.size() || (target & 3))
 			break;
 		Macro macro;
-		macro.id = READ_LE_UINT32(projectData.data() + record);
+		macro.id = (uint32)id;
 		macro.offset = (uint32)target;
 		macros.push_back(macro);
 	}
@@ -269,12 +351,14 @@ bool RA2PSXSoundBank::getMacroCommand(uint16 macro, uint16 step, byte *command)
 	return false;
 }
 
-Audio::RewindableAudioStream *RA2PSXSoundBank::makeStream(uint16 id, uint32 rate,
+Audio::RewindableAudioStream *RA2PSXSoundBank::makeStream(uint16 id, uint32 macroRate,
 		uint16 adsrId) const {
 	const Sample *sample = findSample(id);
 	if (!sample)
 		return nullptr;
 
+	const uint32 rate = macroRate ? macroRate : sample->rate;
+
 	const uint32 size = (uint32)sample->blocks * 16;
 	byte *copy = (byte *)malloc(size);
 	if (!copy)
@@ -283,8 +367,7 @@ Audio::RewindableAudioStream *RA2PSXSoundBank::makeStream(uint16 id, uint32 rate
 
 	Common::SeekableReadStream *source =
 			new Common::MemoryReadStream(copy, size, DisposeAfterUse::YES);
-	Audio::RewindableAudioStream *stream =
-			Audio::makeXAStream(source, rate ? rate : sample->rate);
+	Audio::RewindableAudioStream *stream = Audio::makeXAStream(source, rate);
 	const ADSR *adsr = findADSR(adsrId);
 	if (adsr)
 		stream = new RA2PSXADSRStream(stream, adsr->attack, adsr->decay, adsr->sustain);
@@ -302,7 +385,9 @@ struct RA2PSXSoundPlayer::Impl {
 	struct Voice {
 		Voice() : active(false), waiting(false), waitForSampleEnd(false), macroDone(false),
 				root(false), sound(0), macro(0), step(0), adsr(0xffff), rate(0),
-				rateOverride(-1), volume(0), pan(64), priority(0), born(0), readyTick(0),
+				sampleId(0), bendUp(2), bendDown(2),
+				pitch(RA2PSXSoundPlayer::kNeutralBend), volume(0), pan(64), priority(0),
+				born(0), readyTick(0),
 				startedAt(0), waitUntil(0) {}
 
 		bool active;
@@ -314,8 +399,12 @@ struct RA2PSXSoundPlayer::Impl {
 		uint16 macro;
 		uint16 step;
 		uint16 adsr;
+		uint16 sampleId;
+		RA2PSXPitchRef pitchRef;
+		int bendUp;
+		int bendDown;
 		uint32 rate;
-		int rateOverride;
+		int pitch;
 		int volume;
 		int pan;
 		byte priority;
@@ -365,13 +454,19 @@ struct RA2PSXSoundPlayer::Impl {
 		voices[index] = Voice();
 	}
 
-	void stopGroup(int index) {
-		const SoundId sound = groups[index].sound;
+	// Keyed on the sound, not the group: the group can be gone while voices play.
+	void stopSound(SoundId sound) {
 		for (int i = 0; i < kVoiceCount; ++i) {
 			if (voices[i].active && voices[i].sound == sound)
 				clearVoice(i);
 		}
-		groups[index] = Group();
+		const int group = findGroup(sound);
+		if (group >= 0)
+			groups[group] = Group();
+	}
+
+	void stopGroup(int index) {
+		stopSound(groups[index].sound);
 	}
 
 	int allocateGroup(SoundId sound) {
@@ -433,7 +528,7 @@ struct RA2PSXSoundPlayer::Impl {
 	}
 
 	int startVoice(SoundId sound, uint16 macro, uint16 step, byte priority,
-			byte maxVoices, int volume, int pan, int rateOverride, bool root,
+			byte maxVoices, int volume, int pan, int pitch, bool root,
 			uint32 now, int excluded) {
 		const int slot = allocateVoice(macro, priority, maxVoices, excluded);
 		if (slot < 0)
@@ -445,7 +540,7 @@ struct RA2PSXSoundPlayer::Impl {
 		voice.sound = sound;
 		voice.macro = macro;
 		voice.step = step;
-		voice.rateOverride = rateOverride;
+		voice.pitch = pitch;
 		voice.volume = CLIP(volume, 0, 127);
 		voice.pan = CLIP(pan, 0, 127);
 		voice.priority = priority;
@@ -470,11 +565,8 @@ struct RA2PSXSoundPlayer::Impl {
 	}
 
 	void finishVoice(int index) {
-		const SoundId sound = voices[index].sound;
 		if (voices[index].root) {
-			const int group = findGroup(sound);
-			if (group >= 0)
-				stopGroup(group);
+			stopSound(voices[index].sound);
 			return;
 		}
 		voices[index].macroDone = true;
@@ -505,8 +597,11 @@ struct RA2PSXSoundPlayer::Impl {
 					milliseconds = randomState % milliseconds;
 				}
 				voice.waitUntil = command[4] ? voice.startedAt + milliseconds : now + milliseconds;
-				voice.waitForSampleEnd = command[3] != 0;
+				// Byte 1 bit 0 also ends on the sample; 0xffff means no limit.
+				voice.waitForSampleEnd = (command[1] & 1) != 0;
 				voice.waiting = milliseconds != 0;
+				if (milliseconds == 0xffff)
+					voice.waitUntil = now + 0xffffff;
 				if (voice.root && nextCommandEnds(voice))
 					setGroupExpiry(voice.sound, voice.waitUntil);
 				if (voice.waiting)
@@ -518,27 +613,21 @@ struct RA2PSXSoundPlayer::Impl {
 				const uint16 step = READ_LE_UINT16(command + 4);
 				if (macro != voice.macro) {
 					startVoice(voice.sound, macro, step, command[6], command[7],
-							voice.volume, voice.pan, -1, false, now, index);
+							voice.volume, voice.pan, voice.pitch, false, now, index);
 				}
 				break;
 			}
 			case 0xc:
 				voice.adsr = READ_LE_UINT16(command + 1);
 				break;
+			case 0x33:
+				voice.bendUp = command[1] ? command[1] : 1;
+				voice.bendDown = command[2] ? command[2] : 1;
+				break;
 			case 0x10: {
 				vm->_mixer->stopHandle(voice.handle);
-				const uint32 rate = voice.rateOverride >= 0 ?
-						(uint32)voice.rateOverride : voice.rate;
-				if (rate != 0 || voice.rateOverride < 0) {
-					Audio::RewindableAudioStream *stream = bank.makeStream(
-							READ_LE_UINT16(command + 1), rate, voice.adsr);
-					if (stream) {
-						vm->_mixer->playStream(Audio::Mixer::kSFXSoundType, &voice.handle,
-								stream, -1,
-								voice.volume * Audio::Mixer::kMaxChannelVolume / 127,
-								soundBalance(voice.pan));
-					}
-				}
+				voice.sampleId = READ_LE_UINT16(command + 1);
+				startSample(index);
 				break;
 			}
 			case 0x1f:
@@ -552,7 +641,22 @@ struct RA2PSXSoundPlayer::Impl {
 		finishVoice(index);
 	}
 
-	SoundId play(uint16 sfxId, int volume, int pan, int rate) {
+	void startSample(int index) {
+		Voice &voice = voices[index];
+		if (!voice.sampleId)
+			return;
+		Audio::RewindableAudioStream *stream = bank.makeStream(voice.sampleId, voice.rate,
+				voice.adsr);
+		if (!stream)
+			return;
+		voice.pitchRef = RA2PSXPitchRef(new RA2PSXPitch(
+				bendToStep(voice.pitch, voice.bendUp, voice.bendDown)));
+		stream = new RA2PSXPitchStream(stream, voice.pitchRef);
+		vm->_mixer->playStream(Audio::Mixer::kSFXSoundType, &voice.handle, stream, -1,
+				voice.volume * Audio::Mixer::kMaxChannelVolume / 127, soundBalance(voice.pan));
+	}
+
+	SoundId play(uint16 sfxId, int volume, int pan, int pitch) {
 		uint16 macro;
 		byte priority;
 		byte maxVoices;
@@ -565,7 +669,7 @@ struct RA2PSXSoundPlayer::Impl {
 		const int group = allocateGroup(sound);
 		const uint32 now = g_system->getMillis();
 		const int voice = startVoice(sound, macro, 0, priority, maxVoices,
-				volume, pan, rate, true, now, -1);
+				volume, pan, pitch, true, now, -1);
 		if (voice < 0) {
 			groups[group] = Group();
 			return kInvalidSoundId;
@@ -632,10 +736,32 @@ struct RA2PSXSoundPlayer::Impl {
 		}
 	}
 
+	void setVolume(SoundId sound, int volume) {
+		volume = CLIP(volume, 0, 127);
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (!voices[i].active || voices[i].sound != sound)
+				continue;
+			voices[i].volume = volume;
+			if (vm->_mixer->isSoundHandleActive(voices[i].handle))
+				vm->_mixer->setChannelVolume(voices[i].handle,
+						volume * Audio::Mixer::kMaxChannelVolume / 127);
+		}
+	}
+
+	void setPitch(SoundId sound, int pitch) {
+		pitch = CLIP(pitch, 0, 0x3fff);
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (!voices[i].active || voices[i].sound != sound)
+				continue;
+			voices[i].pitch = pitch;
+			if (voices[i].pitchRef)
+				voices[i].pitchRef->pitch =
+						bendToStep(pitch, voices[i].bendUp, voices[i].bendDown);
+		}
+	}
+
 	void stop(SoundId sound) {
-		const int group = findGroup(sound);
-		if (group >= 0)
-			stopGroup(group);
+		stopSound(sound);
 	}
 
 	void stopAll() {
@@ -656,8 +782,8 @@ RA2PSXSoundPlayer::~RA2PSXSoundPlayer() {
 }
 
 RA2PSXSoundPlayer::SoundId RA2PSXSoundPlayer::play(uint16 sfx, int volume,
-		int pan, int rate) {
-	return _impl->play(sfx, volume, pan, rate);
+		int pan, int pitch) {
+	return _impl->play(sfx, volume, pan, pitch);
 }
 
 void RA2PSXSoundPlayer::update() {
@@ -668,6 +794,14 @@ void RA2PSXSoundPlayer::setPan(SoundId sound, int pan) {
 	_impl->setPan(sound, pan);
 }
 
+void RA2PSXSoundPlayer::setVolume(SoundId sound, int volume) {
+	_impl->setVolume(sound, volume);
+}
+
+void RA2PSXSoundPlayer::setPitch(SoundId sound, int pitch) {
+	_impl->setPitch(sound, pitch);
+}
+
 void RA2PSXSoundPlayer::stop(SoundId sound) {
 	_impl->stop(sound);
 }
diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 24bef8053b4..fab83729dff 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -40,10 +40,46 @@ namespace Scumm {
 #ifdef USE_TINYGL
 static const int kLevel1FrameRate = 30;
 
+// The original pans as (x * 640 / z) / 2 + 0x40, saturating before the edges.
 static int getLevel1SoundPan(float screenX) {
-	return CLIP<int>((int)(screenX * 127.0f / 320.0f), 0, 127);
+	return CLIP<int>((int)((screenX - 160.0f) * 0.5f) + 64, 0, 127);
 }
 
+enum {
+	kLevel1SfxTieApproachA = 0x19,
+	kLevel1SfxTieApproachB = 0x1a,
+	kLevel1SfxTieExplode = 0x1b,
+	kLevel1SfxTieFire = 0x17,
+	kLevel1SfxPlayerFire = 0x18,
+	kLevel1SfxPlayerHit = 0x36,
+	kLevel1SfxLowShield = 0x2d,
+	kLevel1SfxEngineLeft = 0x32,
+	kLevel1SfxEngineRight = 0x33,
+	kLevel1SfxEngineOutside = 0x41,
+	kLevel1SfxExtraLife = 0x44
+};
+
+// Outside engine base volume, and the per-frame step of the mix cross-fade.
+enum {
+	kLevel1OutsideBase = 0x46,
+	kLevel1MixStep = 2,
+	kLevel1MixMaximum = 0x7f
+};
+
+static int approachMix(int current, int target) {
+	if (current < target)
+		return MIN(target, current + kLevel1MixStep);
+	return MAX(target, current - kLevel1MixStep);
+}
+
+// Points per kill and the extra life thresholds, by difficulty.
+static const int kLevel1KillScore[3] = { 80, 100, 150 };
+static const int kLevel1ExtraLife[3][3] = {
+	{ 5000, 5000, 10000 },
+	{ 5000, 5000, 30000 },
+	{ 10000, 20000, 30000 }
+};
+
 struct RA2PSXLevel1Enemy {
 	RA2PSXLevel1Enemy() : active(false), pattern(0), age(0), lifetime(0), fireFrame(0),
 			laserFrames(0), startX(0), startY(0), controlX(0), controlY(0), endX(0), endY(0),
@@ -388,6 +424,20 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	RA2PSXLevel1Ship ship;
 	RA2PSXSoundPlayer soundPlayer(_vm, _soundBank);
 	RA2PSXSoundPlayer::SoundId approachSounds[3] = {};
+	// A hard left/right pair for the cockpit and a centred one for outside,
+	// cross-faded as the view changes.
+	const RA2PSXSoundPlayer::SoundId engineLeft =
+			soundPlayer.play(kLevel1SfxEngineLeft, 0x28, 0x00);
+	const RA2PSXSoundPlayer::SoundId engineRight =
+			soundPlayer.play(kLevel1SfxEngineRight, 0x28, 0x7f);
+	const RA2PSXSoundPlayer::SoundId engineOutside =
+			soundPlayer.play(kLevel1SfxEngineOutside, kLevel1OutsideBase, 0x40);
+	RA2PSXSoundPlayer::SoundId lowShieldAlarm = RA2PSXSoundPlayer::kInvalidSoundId;
+	int cockpitMix = -1;
+	int outsideMix = -1;
+	const int difficulty = CLIP(_settings.difficulty, 0, 2);
+	int extraLifeStage = 0;
+	int nextExtraLife = kLevel1ExtraLife[difficulty][0];
 	int aimX = 160;
 	int aimY = 113;
 	int aimVelocityX = 0;
@@ -608,6 +658,30 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 						left, right, up, down);
 			}
 
+			const int cockpitTarget = thirdPersonView ? 0 : kLevel1MixMaximum;
+			const int outsideTarget = thirdPersonView ? kLevel1MixMaximum : 0;
+			if (cockpitMix != cockpitTarget || outsideMix != outsideTarget) {
+				cockpitMix = cockpitMix < 0 ? cockpitTarget :
+						approachMix(cockpitMix, cockpitTarget);
+				outsideMix = outsideMix < 0 ? outsideTarget :
+						approachMix(outsideMix, outsideTarget);
+				soundPlayer.setVolume(engineLeft, cockpitMix);
+				soundPlayer.setVolume(engineRight, cockpitMix);
+				soundPlayer.setVolume(engineOutside, outsideMix);
+			} else if (thirdPersonView) {
+				// Outside, the engine tracks how hard the ship is turning.
+				const int turn = MAX(ABS(ship.velocityX), ABS(ship.velocityY));
+				soundPlayer.setPitch(engineOutside, 0x2000 + turn);
+				soundPlayer.setVolume(engineOutside, kLevel1OutsideBase +
+						turn / (0xa0 - kLevel1OutsideBase));
+				soundPlayer.setPan(engineOutside,
+						CLIP<int>(ship.x * 4096 / 0x479f + 64, 0, 127));
+			}
+
+			// Started once, then left running, as in the original.
+			if (shield <= 31 && lowShieldAlarm == RA2PSXSoundPlayer::kInvalidSoundId)
+				lowShieldAlarm = soundPlayer.play(kLevel1SfxLowShield, 0x50, 0x40);
+
 			if (logicFrame >= nextSpawnAdjustment) {
 				nextSpawnAdjustment = logicFrame + 20;
 				spawnRange = MAX(40, spawnRange - 1);
@@ -628,9 +702,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					}
 				}
 				if (spawnedEnemy >= 0) {
-					const uint16 sfx = _vm->_rnd.getRandomNumber(999) < 800 ? 0x19 : 0x1a;
-					const int rate = 0x1c18 + _vm->_rnd.getRandomNumber(1999);
-					approachSounds[spawnedEnemy] = soundPlayer.play(sfx, 0x5e, 0x40, rate);
+					const uint16 sfx = _vm->_rnd.getRandomNumber(999) < 800 ?
+							kLevel1SfxTieApproachA : kLevel1SfxTieApproachB;
+					const int pitch = 0x1c18 + _vm->_rnd.getRandomNumber(1999);
+					approachSounds[spawnedEnemy] = soundPlayer.play(sfx, 0x5e, 0x40, pitch);
 				}
 				spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
 			}
@@ -650,21 +725,21 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				soundPlayer.setPan(approachSounds[i], soundPan);
 				if (enemies[i].age == enemies[i].fireFrame) {
 					enemies[i].laserFrames = 4;
-					soundPlayer.play(0x17, 0x4e, soundPan);
+					soundPlayer.play(kLevel1SfxTieFire, 0x4e, soundPan);
 					if (_vm->_rnd.getRandomNumber(99) < 38) {
 						shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
-						soundPlayer.play(0x36, 0x7f, 0x40);
+						soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
 					}
 				}
 				if (enemies[i].age >= enemies[i].lifetime) {
-					const int rate = _vm->_rnd.getRandomNumber(0x3fff);
-					soundPlayer.play(0x1b, 0x5a, soundPan, rate);
+					const int pitch = _vm->_rnd.getRandomNumber(0x3fff);
+					soundPlayer.play(kLevel1SfxTieExplode, 0x5a, soundPan, pitch);
 					soundPlayer.stop(approachSounds[i]);
 					approachSounds[i] = RA2PSXSoundPlayer::kInvalidSoundId;
 					enemies[i].active = false;
 					if (_vm->_rnd.getRandomNumber(99) < 18) {
 						shield = MAX(0, shield - 12);
-						soundPlayer.play(0x36, 0x7f, 0x40);
+						soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
 					}
 				}
 			}
@@ -681,7 +756,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				if (!spawnLevel1Shot(shots, aimX, aimY,
 						thirdPersonView ? &ship : nullptr, shotTargetX, shotTargetY))
 					continue;
-				soundPlayer.play(0x18, 0x3f, 0x40);
+				soundPlayer.play(kLevel1SfxPlayerFire, 0x3f, 0x40);
 				int hitEnemy = -1;
 				float hitDistance = 1000000.0f;
 				for (int i = 0; i < 3; ++i) {
@@ -698,12 +773,18 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				}
 				if (hitEnemy >= 0) {
 					const int soundPan = getLevel1SoundPan(enemies[hitEnemy].x);
-					const int rate = _vm->_rnd.getRandomNumber(0x3fff);
-					soundPlayer.play(0x1b, 0x5a, soundPan, rate);
+					const int pitch = _vm->_rnd.getRandomNumber(0x3fff);
+					soundPlayer.play(kLevel1SfxTieExplode, 0x5a, soundPan, pitch);
 					soundPlayer.stop(approachSounds[hitEnemy]);
 					approachSounds[hitEnemy] = RA2PSXSoundPlayer::kInvalidSoundId;
 					enemies[hitEnemy].active = false;
-					score = MIN(9999999, score + 100);
+					score = MIN(9999999, score + kLevel1KillScore[difficulty]);
+					if (score >= nextExtraLife) {
+						// The original also awards a life; the shared runner owns it.
+						soundPlayer.play(kLevel1SfxExtraLife, 0x7f, 0x40);
+						extraLifeStage = MIN(extraLifeStage + 1, 2);
+						nextExtraLife += kLevel1ExtraLife[difficulty][extraLifeStage];
+					}
 					for (int i = 0; i < 3; ++i) {
 						if (explosions[i].frames == 0) {
 							explosions[i].frames = 10;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 24f003d10af..e8dd3b72a4c 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -74,7 +74,7 @@ private:
 class RA2PSXSoundBank {
 public:
 	bool load(const Common::Array<byte> &sampleData, const Common::Array<byte> &projectData);
-	Audio::RewindableAudioStream *makeStream(uint16 id, uint32 rate,
+	Audio::RewindableAudioStream *makeStream(uint16 id, uint32 macroRate,
 			uint16 adsrId = 0xffff) const;
 	bool getSFX(uint16 id, uint16 &macro, byte &priority, byte &maxVoices) const;
 	bool getMacroCommand(uint16 macro, uint16 step, byte *command) const;
@@ -123,9 +123,14 @@ public:
 	RA2PSXSoundPlayer(ScummEngine_v7 *vm, const RA2PSXSoundBank &bank);
 	~RA2PSXSoundPlayer();
 
-	SoundId play(uint16 sfx, int volume, int pan, int rate = -1);
+	// Pitch is a 14 bit bend; kNeutralBend leaves the sound at its own rate.
+	enum { kNeutralBend = 0x2000 };
+
+	SoundId play(uint16 sfx, int volume, int pan, int pitch = kNeutralBend);
 	void update();
 	void setPan(SoundId sound, int pan);
+	void setVolume(SoundId sound, int volume);
+	void setPitch(SoundId sound, int pitch);
 	void stop(SoundId sound);
 	void stopAll();
 


Commit: 8420adc2deacde75ebc91d83a13fa484ab818952
    https://github.com/scummvm/scummvm/commit/8420adc2deacde75ebc91d83a13fa484ab818952
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: improved movement and gameplay of L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/iact.cpp
    engines/scumm/insane/rebel2/levels.cpp
    engines/scumm/insane/rebel2/menu.cpp
    engines/scumm/insane/rebel2/psx/audio.cpp
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/menu.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/video.cpp
    engines/scumm/insane/rebel2/render.cpp


diff --git a/engines/scumm/insane/rebel2/iact.cpp b/engines/scumm/insane/rebel2/iact.cpp
index 0af5ddacdf5..3c8b5838065 100644
--- a/engines/scumm/insane/rebel2/iact.cpp
+++ b/engines/scumm/insane/rebel2/iact.cpp
@@ -38,7 +38,7 @@ const int kRA2Handler7DirectInputNumerator = 4;
 const int kRA2Handler7DirectInputDenominator = 5;
 const int kRA2Handler7MouseTargetRangeX = 0xc0;
 
-static bool readLevel2BackgroundChunkHeader(Common::SeekableReadStream &stream, int64 containerEnd, const char *context,
+bool readLevel2BackgroundChunkHeader(Common::SeekableReadStream &stream, int64 containerEnd, const char *context,
 		uint32 &tag, uint32 &chunkSize, int64 &dataEnd, int64 &nextChunkPos) {
 	const int64 headerPos = stream.pos();
 	if (headerPos < 0 || headerPos + 8 > containerEnd)
diff --git a/engines/scumm/insane/rebel2/levels.cpp b/engines/scumm/insane/rebel2/levels.cpp
index 0aa7e4e5f36..ec758c094c3 100644
--- a/engines/scumm/insane/rebel2/levels.cpp
+++ b/engines/scumm/insane/rebel2/levels.cpp
@@ -33,9 +33,9 @@
 
 namespace Scumm {
 
-static const int kRebel2GameplayAimCenterX = 160;
-static const int kRebel2GameplayAimCenterY = 100;
-static const uint32 kRebel2GameplayMouseSettleMs = 1000;
+const int kRebel2GameplayAimCenterX = 160;
+const int kRebel2GameplayAimCenterY = 100;
+const uint32 kRebel2GameplayMouseSettleMs = 1000;
 
 struct Rebel2LevelEndParams {
 	int titleStartBeforeEnd;
@@ -46,7 +46,7 @@ struct Rebel2LevelEndParams {
 	int errHigh;
 };
 
-static const Rebel2LevelEndParams kRebel2LevelEndParams[16] = {
+const Rebel2LevelEndParams kRebel2LevelEndParams[16] = {
 	{ 0,   0,  -1,  -1, -1, -1 },
 	{ 120, 10,  96, 100, -1, -1 },
 	{ 120, 10,  80,  94, -1, -1 },
@@ -65,7 +65,7 @@ static const Rebel2LevelEndParams kRebel2LevelEndParams[16] = {
 	{ 100, 10,  60,  68,  1,  3 }
 };
 
-static void purgeRebel2GameplayInputEvents(Common::EventManager *eventMan) {
+void purgeRebel2GameplayInputEvents(Common::EventManager *eventMan) {
 	if (!eventMan)
 		return;
 
diff --git a/engines/scumm/insane/rebel2/menu.cpp b/engines/scumm/insane/rebel2/menu.cpp
index c646f42aa59..59cd9529d1c 100644
--- a/engines/scumm/insane/rebel2/menu.cpp
+++ b/engines/scumm/insane/rebel2/menu.cpp
@@ -41,7 +41,7 @@
 
 namespace Scumm {
 
-static void setRebel2MixerVolume(ScummEngine_v7 *vm, int volumeLevel) {
+void setRebel2MixerVolume(ScummEngine_v7 *vm, int volumeLevel) {
 	const int mixerVolume = CLIP<int>(volumeLevel * 2, 0, (int)Audio::Mixer::kMaxMixerVolume);
 	vm->_mixer->setVolumeForSoundType(Audio::Mixer::kMusicSoundType, mixerVolume);
 	vm->_mixer->setVolumeForSoundType(Audio::Mixer::kSFXSoundType, mixerVolume);
diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
index 6d1bb0fb137..39cba3ff0df 100644
--- a/engines/scumm/insane/rebel2/psx/audio.cpp
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -89,7 +89,7 @@ private:
 
 // A 14 bit bend centred on kNeutralBend, scaled into semitones by the range
 // macro command 0x33 sets.
-static int bendToStep(int bend, int rangeUp, int rangeDown) {
+int bendToStep(int bend, int rangeUp, int rangeDown) {
 	const int offset = CLIP(bend, 0, 0x3fff) - RA2PSXSoundPlayer::kNeutralBend;
 	if (!offset)
 		return 0x1000;
@@ -163,15 +163,15 @@ private:
 	int16 _current[2];
 };
 
-static bool matchesTag(const Common::Array<byte> &data, uint32 offset, const char *tag) {
+bool matchesTag(const Common::Array<byte> &data, uint32 offset, const char *tag) {
 	return offset + 4 <= data.size() && !memcmp(data.data() + offset, tag, 4);
 }
 
-static bool timeReached(uint32 now, uint32 target) {
+bool timeReached(uint32 now, uint32 target) {
 	return (int32)(now - target) >= 0;
 }
 
-static int soundBalance(int pan) {
+int soundBalance(int pan) {
 	// "sound mode: mono" in the options menu collapses the panning.
 	if (ConfMan.hasKey("rebel2_mono") && ConfMan.getBool("rebel2_mono"))
 		return 0;
diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index fab83729dff..4102c542c9f 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -38,12 +38,7 @@
 namespace Scumm {
 
 #ifdef USE_TINYGL
-static const int kLevel1FrameRate = 30;
-
-// The original pans as (x * 640 / z) / 2 + 0x40, saturating before the edges.
-static int getLevel1SoundPan(float screenX) {
-	return CLIP<int>((int)((screenX - 160.0f) * 0.5f) + 64, 0, 127);
-}
+const int kLevel1FrameRate = 30;
 
 enum {
 	kLevel1SfxTieApproachA = 0x19,
@@ -66,43 +61,257 @@ enum {
 	kLevel1MixMaximum = 0x7f
 };
 
-static int approachMix(int current, int target) {
+int approachMix(int current, int target) {
 	if (current < target)
 		return MIN(target, current + kLevel1MixStep);
 	return MAX(target, current - kLevel1MixStep);
 }
 
 // Points per kill and the extra life thresholds, by difficulty.
-static const int kLevel1KillScore[3] = { 80, 100, 150 };
-static const int kLevel1ExtraLife[3][3] = {
+const int kLevel1KillScore[3] = { 80, 100, 150 };
+const int kLevel1ExtraLife[3][3] = {
 	{ 5000, 5000, 10000 },
 	{ 5000, 5000, 30000 },
 	{ 10000, 20000, 30000 }
 };
 
+// The TIEs fly five point Hermite splines in world space; angles run 4096 units to
+// the turn and fractions are 1/4096.
+enum {
+	kLevel1SplinePoints = 5,
+	kLevel1SplineStep = 0x40,
+	kLevel1LeadFrames = 30,
+	kLevel1EnemyCount = 3,
+	kLevel1ShotCount = 8,
+	kLevel1ShieldFull = 0x1000,
+	kLevel1LowShield = 0x501,
+	kLevel1FireFacing = 0xf3c,
+	kLevel1HitRadius = 0xc000
+};
+
+const int kLevel1SplineDepth[kLevel1SplinePoints] = { 17000, 14000, 11000, -4000, -8000 };
+
+// Bolt damage by view then difficulty, and the far heavier damage from a TIE that rams.
+const int kLevel1BoltDamage[2][3] = { { 100, 200, 240 }, { 112, 160, 220 } };
+const int kLevel1RamDamage[3] = { 256, 512, 1024 };
+// Per cent chance that a shot is aimed at the player instead of fired straight ahead.
+const int kLevel1AimChance[2][3] = { { 5, 5, 5 }, { 15, 20, 20 } };
+// A TIE that gets this close without being shot collides with the player.
+const int kLevel1RamRadius[2] = { 200, 220 };
+
+// The original truncates its 1/4096 products toward zero.
+int fixedShift12(int value) {
+	return (value < 0 ? value + 0xfff : value) >> 12;
+}
+
+void normalizeVector12(const int *source, int *result) {
+	const int x = source[0];
+	const int y = source[1];
+	const int z = source[2];
+	const double square = (double)x * x + (double)y * y + (double)z * z;
+	const int length = MAX(1, (int)sqrt(square));
+	result[0] = x * 4096 / length;
+	result[1] = y * 4096 / length;
+	result[2] = z * 4096 / length;
+}
+
+// asin(value / 4096) in the PlayStation's 4096 units to the turn.
+int lookupAsinAngle(int value) {
+	return (int)(asin(CLIP(value, 0, 4096) / 4096.0) * 651.8986469044033 + 0.5);
+}
+
+int signedAsinAngle(int value) {
+	return value < 0 ? -lookupAsinAngle(-value) : lookupAsinAngle(value);
+}
+
+struct RA2PSXLevel1Spline {
+	RA2PSXLevel1Spline() {
+		memset(this, 0, sizeof(*this));
+		segmentCount = kLevel1SplinePoints;
+		step = kLevel1SplineStep;
+	}
+
+	int control[kLevel1SplinePoints][3];
+	int tangentA[3];
+	int tangentB[3];
+	int previous[3];
+	int coefficient[3][4];
+	int position[3];
+	int segment;
+	int segmentCount;
+	int t;
+	int step;
+};
+
+void updateSplineTangents(RA2PSXLevel1Spline &spline, int segment) {
+	for (int axis = 0; axis < 3; ++axis)
+		spline.tangentA[axis] = spline.tangentB[axis];
+
+	if (segment == 0) {
+		for (int axis = 0; axis < 3; ++axis) {
+			spline.tangentB[axis] = spline.control[1][axis] - spline.control[0][axis];
+			spline.previous[axis] = spline.tangentB[axis];
+			spline.tangentA[axis] = spline.control[0][axis];
+		}
+		// The original only overwrites x and y, leaving z at the first control point.
+		spline.tangentA[0] = 1;
+		spline.tangentA[1] = 1;
+	} else if (segment == spline.segmentCount - 1) {
+		for (int axis = 0; axis < 3; ++axis)
+			spline.tangentB[axis] = spline.previous[axis];
+	} else {
+		for (int axis = 0; axis < 3; ++axis) {
+			const int old = spline.previous[axis];
+			spline.previous[axis] = spline.control[segment + 1][axis] -
+					spline.control[segment][axis];
+			spline.tangentB[axis] = (old + spline.previous[axis]) / 2;
+		}
+	}
+}
+
+// Hermite basis, one row per source term and one column per power of t.
+const int kLevel1SplineBasis[4][4] = {
+	{ 2, -3, 0, 1 },
+	{ -2, 3, 0, 0 },
+	{ 1, -2, 1, 0 },
+	{ 1, -1, 0, 0 }
+};
+
+void buildSplineSegmentCoefficients(RA2PSXLevel1Spline &spline, int segment) {
+	if (segment < 1 || segment >= kLevel1SplinePoints)
+		return;
+	for (int axis = 0; axis < 3; ++axis) {
+		const int source[4] = {
+			spline.control[segment - 1][axis], spline.control[segment][axis],
+			spline.tangentA[axis], spline.tangentB[axis]
+		};
+		for (int power = 0; power < 4; ++power) {
+			int value = 0;
+			for (int term = 0; term < 4; ++term)
+				value += source[term] * kLevel1SplineBasis[term][power];
+			spline.coefficient[axis][power] = value;
+		}
+	}
+}
+
+void advanceSplineObject(RA2PSXLevel1Spline &spline) {
+	const int t1 = spline.t;
+	const int t2 = t1 * t1 >> 12;
+	const int t3 = t1 * t2 >> 12;
+	for (int axis = 0; axis < 3; ++axis)
+		spline.position[axis] = (int16)(((spline.coefficient[axis][0] * t3 +
+				spline.coefficient[axis][1] * t2 +
+				spline.coefficient[axis][2] * t1) >> 12) + spline.coefficient[axis][3]);
+
+	spline.t += spline.step;
+	if (spline.t <= 0xfff)
+		return;
+	spline.t -= 0x1000;
+	if (++spline.segment == spline.segmentCount)
+		return;
+	updateSplineTangents(spline, spline.segment);
+	buildSplineSegmentCoefficients(spline, spline.segment + 1);
+}
+
+void randomizeTieSplineControlPoints(RA2PSXLevel1Spline &spline, int mode,
+		int spread, int base, Common::RandomSource &random) {
+	if (mode < 1 || mode > 3)
+		return;
+	const int offset = base / 2;
+	if (mode != 1) {
+		for (int axis = 0; axis < 2; ++axis)
+			spline.control[1][axis] += (int)random.getRandomNumber(spread * 2 - 1) -
+					spread + offset;
+	}
+	if (mode != 2) {
+		for (int axis = 0; axis < 2; ++axis)
+			spline.control[2][axis] += (int)random.getRandomNumber(spread * 2 - 1) -
+					spread + offset;
+	}
+	if (mode == 1)
+		return;
+	for (int axis = 0; axis < 2; ++axis)
+		spline.control[3][axis] += (int)random.getRandomNumber(spread * 2 - 1) - spread + offset;
+}
+
+// Head on attack run: the far and near ends are picked first, the rest strung between them.
+void initTieSplinePatternA(RA2PSXLevel1Spline &spline, Common::RandomSource &random) {
+	spline = RA2PSXLevel1Spline();
+
+	int last = (int)random.getRandomNumber(349) + 500;
+	if (!random.getRandomBit())
+		last = -last;
+	spline.control[4][0] = last;
+	last = (int)random.getRandomNumber(349) + 500;
+	if (!random.getRandomBit())
+		last = -last;
+	spline.control[4][1] = last;
+	spline.control[4][2] = kLevel1SplineDepth[4];
+
+	const int lead = spline.control[4][0] > 0 ? -kLevel1SplineDepth[0] : kLevel1SplineDepth[0];
+	spline.control[0][0] = (int)random.getRandomNumber(179) * lead / 640;
+	spline.control[0][1] = spline.control[4][1] < 1 ? 0 :
+			(int)random.getRandomNumber(139) * -kLevel1SplineDepth[0] / 640;
+	spline.control[0][2] = kLevel1SplineDepth[0];
+
+	int direction[3];
+	for (int axis = 0; axis < 3; ++axis)
+		direction[axis] = spline.control[0][axis] - spline.control[4][axis];
+	normalizeVector12(direction, direction);
+	for (int point = 3; point >= 1; --point) {
+		const int depth = kLevel1SplineDepth[point];
+		spline.control[point][0] = fixedShift12(direction[0] * depth) + spline.control[4][0];
+		spline.control[point][1] = fixedShift12(direction[1] * depth) + spline.control[4][1];
+		spline.control[point][2] = depth;
+	}
+
+	randomizeTieSplineControlPoints(spline, (int)random.getRandomNumber(2),
+			(int)random.getRandomNumber(499) + 1000, 800, random);
+	updateSplineTangents(spline, 0);
+	buildSplineSegmentCoefficients(spline, 1);
+}
+
+// Loose weave: every control point is scattered across the view at its own depth.
+void initTieSplinePatternB(RA2PSXLevel1Spline &spline, Common::RandomSource &random) {
+	spline = RA2PSXLevel1Spline();
+
+	for (int point = 0; point < kLevel1SplinePoints - 1; ++point) {
+		const int depth = kLevel1SplineDepth[point];
+		spline.control[point][2] = depth;
+		spline.control[point][0] = ((int)random.getRandomNumber(259) - 130) * depth / 640;
+		spline.control[point][1] = ((int)random.getRandomNumber(179) - 90) * depth / 640;
+	}
+
+	const int depth = kLevel1SplineDepth[kLevel1SplinePoints - 1];
+	spline.control[4][2] = depth;
+	int offset = (int)random.getRandomNumber(299) + 800;
+	if ((int)random.getRandomNumber(1999) > 1000)
+		offset = -offset;
+	spline.control[4][0] = offset * depth / 640;
+	offset = (int)random.getRandomNumber(299) + 600;
+	if ((int)random.getRandomNumber(1999) > 1000)
+		offset = -offset;
+	spline.control[4][1] = offset * depth / 640;
+
+	spline.control[0][0] += (int)random.getRandomNumber(2999) - 1500;
+	spline.control[0][1] += (int)random.getRandomNumber(1499) - 750;
+	updateSplineTangents(spline, 0);
+	buildSplineSegmentCoefficients(spline, 1);
+}
+
 struct RA2PSXLevel1Enemy {
-	RA2PSXLevel1Enemy() : active(false), pattern(0), age(0), lifetime(0), fireFrame(0),
-			laserFrames(0), startX(0), startY(0), controlX(0), controlY(0), endX(0), endY(0),
-			x(0), y(0), size(0), pitch(0), yaw(0), roll(0) {}
+	RA2PSXLevel1Enemy() : active(false), previousYaw(0), facing(0), fireCountdown(0) {
+		rotation[0] = rotation[1] = rotation[2] = 0;
+	}
 
 	bool active;
-	int pattern;
-	int age;
-	int lifetime;
-	int fireFrame;
-	int laserFrames;
-	float startX;
-	float startY;
-	float controlX;
-	float controlY;
-	float endX;
-	float endY;
-	float x;
-	float y;
-	float size;
-	float pitch;
-	float yaw;
-	float roll;
+	RA2PSXLevel1Spline path;
+	// A copy of the path run 30 frames ahead; the gap gives the heading.
+	RA2PSXLevel1Spline lead;
+	int rotation[3];
+	int previousYaw;
+	int facing;
+	int fireCountdown;
 };
 
 struct RA2PSXLevel1Explosion {
@@ -113,6 +322,18 @@ struct RA2PSXLevel1Explosion {
 	int y;
 };
 
+struct RA2PSXLevel1TieShot {
+	RA2PSXLevel1TieShot() : active(false), distance(0), length(0) {}
+
+	bool active;
+	int origin[2][3];
+	int direction[2][3];
+	int yaw[2];
+	int pitch[2];
+	int distance;
+	int length;
+};
+
 struct RA2PSXLevel1Shot {
 	RA2PSXLevel1Shot() : active(false), progress(0), targetX(0), targetY(0), targetZ(0) {}
 
@@ -123,6 +344,10 @@ struct RA2PSXLevel1Shot {
 	float targetX;
 	float targetY;
 	float targetZ;
+	// The line the player aims along, sampled once per logic frame for the hit test.
+	float traceStart[3];
+	float trace[3];
+	float previousTrace[3];
 };
 
 struct RA2PSXLevel1Ship {
@@ -135,62 +360,199 @@ struct RA2PSXLevel1Ship {
 	int velocityY;
 };
 
-static const float kLevel1LaserStart[3][3] = {
+const float kLevel1LaserStart[3][3] = {
 	{ -350.0f, 200.0f, 400.0f },
 	{ 350.0f, 200.0f, 400.0f },
 	{ 0.0f, 500.0f, 400.0f }
 };
 
-static const float kLevel1LaserRoll[3] = { -45.0f, 45.0f, 0.0f };
+const float kLevel1LaserRoll[3] = { -45.0f, 45.0f, 0.0f };
 
-static const float kLevel1ShipLaserStart[3][3] = {
+const float kLevel1ShipLaserStart[3][3] = {
 	{ -93.0f, 11.0f, -139.0f },
 	{ 93.0f, 11.0f, -139.0f },
 	{ 4.0f, 210.0f, -111.0f }
 };
 
-static void updateLevel1Enemy(RA2PSXLevel1Enemy &enemy) {
-	const float t = MIN(1.0f, (float)enemy.age / enemy.lifetime);
-	const float inverse = 1.0f - t;
-	enemy.x = inverse * inverse * enemy.startX + 2.0f * inverse * t * enemy.controlX +
-			t * t * enemy.endX;
-	enemy.y = inverse * inverse * enemy.startY + 2.0f * inverse * t * enemy.controlY +
-			t * t * enemy.endY;
-	enemy.size = 5.0f + 72.0f * t * t;
-	enemy.yaw = (enemy.controlX - enemy.startX) * 0.18f + sinf(enemy.age * 0.09f) * 18.0f;
-	enemy.pitch = -8.0f + sinf(enemy.age * 0.06f) * 10.0f;
-	enemy.roll = (enemy.controlY - enemy.startY) * 0.12f + sinf(enemy.age * 0.12f) * 8.0f;
+// The two cannon mounts, in the TIE's own space.
+const int kLevel1TieMuzzle[2][3] = { { -60, 10, -50 }, { 60, 10, -50 } };
+
+void rotateVector(const RA2PSXMatrix &transform, const int *source, int *result) {
+	for (int row = 0; row < 3; ++row) {
+		float value = 0.0f;
+		for (int column = 0; column < 3; ++column)
+			value += transform.rotation[row][column] * source[column];
+		result[row] = (int)value;
+	}
 }
 
-static void spawnLevel1Enemy(RA2PSXLevel1Enemy &enemy, Common::RandomSource &random) {
+void spawnLevel1Enemy(RA2PSXLevel1Enemy &enemy, Common::RandomSource &random) {
 	enemy = RA2PSXLevel1Enemy();
 	enemy.active = true;
-	enemy.pattern = random.getRandomBit();
-	enemy.lifetime = random.getRandomNumberRng(95, 150);
-	enemy.fireFrame = enemy.lifetime * random.getRandomNumberRng(45, 70) / 100;
-
-	const bool fromLeft = random.getRandomBit();
-	if (enemy.pattern == 0) {
-		enemy.startX = fromLeft ? -24.0f : 344.0f;
-		enemy.startY = (float)random.getRandomNumberRng(25, 175);
-		enemy.controlX = (float)random.getRandomNumberRng(95, 225);
-		enemy.controlY = (float)random.getRandomNumberRng(35, 170);
-		enemy.endX = fromLeft ? (float)random.getRandomNumberRng(210, 390) :
-				(float)random.getRandomNumberRngSigned(-70, 110);
+	if (random.getRandomNumber(999) < 500)
+		initTieSplinePatternA(enemy.path, random);
+	else
+		initTieSplinePatternB(enemy.path, random);
+	enemy.lead = enemy.path;
+	enemy.lead.t += enemy.path.step * kLevel1LeadFrames;
+	enemy.rotation[1] = 0x800;
+	enemy.previousYaw = 0x800;
+	enemy.fireCountdown = (int)random.getRandomNumber(14) + 8;
+}
+
+// The TIE points down its own path and banks into the turn; facing measures how squarely
+// that path runs at the player, which is what lets it open fire.
+void orientTieAlongSpline(RA2PSXLevel1Enemy &enemy, int playerX, int playerY) {
+	int delta[3];
+	for (int axis = 0; axis < 3; ++axis)
+		delta[axis] = enemy.path.position[axis] - enemy.lead.position[axis];
+
+	int vector[3] = { 0, delta[1], delta[2] };
+	normalizeVector12(vector, vector);
+	enemy.rotation[0] = signedAsinAngle(vector[1]);
+
+	vector[0] = delta[0];
+	vector[1] = 0;
+	vector[2] = delta[2];
+	normalizeVector12(vector, vector);
+	const int yaw = (int16)(-signedAsinAngle(vector[0]) + 0x800);
+	enemy.rotation[2] = (int16)fixedShift12(((yaw - enemy.previousYaw) * 2 +
+			enemy.rotation[2]) * 0xf00);
+	enemy.rotation[1] = yaw;
+	enemy.previousYaw = yaw;
+
+	for (int axis = 0; axis < 3; ++axis)
+		delta[axis] = enemy.lead.position[axis] - enemy.path.position[axis];
+	normalizeVector12(delta, delta);
+	int toPlayer[3] = {
+		playerX - enemy.path.position[0],
+		playerY - enemy.path.position[1],
+		-enemy.path.position[2]
+	};
+	normalizeVector12(toPlayer, toPlayer);
+	enemy.facing = fixedShift12(delta[0] * toPlayer[0]) + fixedShift12(delta[1] * toPlayer[1]) +
+			fixedShift12(delta[2] * toPlayer[2]);
+}
+
+// pan = (x * 640 / z) / 2 + 0x40, which saturates well before the screen edges.
+int getLevel1WorldPan(const int *position) {
+	if (!position[2])
+		return 0x40;
+	return CLIP<int>((position[0] * 640 / position[2]) / 2 + 0x40, 0, 0x7f);
+}
+
+bool spawnLevel1TieShot(RA2PSXLevel1TieShot *shots, const RA2PSXLevel1Enemy &enemy,
+		bool aimed, bool outsideView, const int *shipPosition, Common::RandomSource &random) {
+	int slot = -1;
+	for (int i = 0; i < kLevel1ShotCount; ++i) {
+		if (!shots[i].active) {
+			slot = i;
+			break;
+		}
+	}
+	if (slot < 0)
+		return false;
+
+	RA2PSXLevel1TieShot &shot = shots[slot];
+	shot = RA2PSXLevel1TieShot();
+	shot.active = true;
+	shot.distance = 0x380;
+	shot.length = 0x100;
+
+	RA2PSXMatrix transform;
+	transform.preRotateX(-enemy.rotation[0]);
+	transform.preRotateY(enemy.rotation[1]);
+	transform.preRotateZ(enemy.rotation[2]);
+
+	// Aimed shots converge on the player; in the cockpit that is a point scattered
+	// across the canopy rather than the camera itself.
+	int target[3];
+	if (outsideView) {
+		for (int axis = 0; axis < 3; ++axis)
+			target[axis] = shipPosition[axis];
 	} else {
-		enemy.startX = (float)random.getRandomNumberRng(35, 285);
-		enemy.startY = (float)random.getRandomNumberRng(20, 155);
-		enemy.controlX = fromLeft ? (float)random.getRandomNumberRng(180, 310) :
-				(float)random.getRandomNumberRng(10, 140);
-		enemy.controlY = (float)random.getRandomNumberRng(20, 190);
-		enemy.endX = fromLeft ? (float)random.getRandomNumberRngSigned(-80, 80) :
-				(float)random.getRandomNumberRng(240, 400);
+		target[0] = (int)random.getRandomNumber(999) - 500;
+		target[1] = (int)random.getRandomNumber(799) - 400;
+		target[2] = 0x280;
+	}
+
+	for (int bolt = 0; bolt < 2; ++bolt) {
+		rotateVector(transform, kLevel1TieMuzzle[bolt], shot.origin[bolt]);
+		for (int axis = 0; axis < 3; ++axis)
+			shot.origin[bolt][axis] += enemy.path.position[axis];
+
+		int *direction = shot.direction[bolt];
+		if (aimed) {
+			for (int axis = 0; axis < 3; ++axis)
+				direction[axis] = enemy.path.position[axis] - target[axis];
+			normalizeVector12(direction, direction);
+		} else {
+			const int forward[3] = { 0, 0, -0x1000 };
+			rotateVector(transform, forward, direction);
+		}
+
+		int vector[3] = { direction[0], 0, direction[2] };
+		normalizeVector12(vector, vector);
+		shot.yaw[bolt] = -signedAsinAngle(vector[0]);
+		vector[0] = 0;
+		vector[1] = direction[1];
+		vector[2] = direction[2];
+		normalizeVector12(vector, vector);
+		shot.pitch[bolt] = signedAsinAngle(vector[1]);
+	}
+	return true;
+}
+
+void getLevel1TieShotPosition(const RA2PSXLevel1TieShot &shot, int bolt, int *position) {
+	for (int axis = 0; axis < 3; ++axis)
+		position[axis] = shot.origin[bolt][axis] - (shot.direction[bolt][axis] * shot.distance >> 12);
+}
+
+// Returns false once the bolt is spent; hit is set only when it reaches the player.
+bool updateLevel1TieShot(RA2PSXLevel1TieShot &shot, bool outsideView,
+		const int *shipPosition, bool &hit) {
+	int position[3];
+	getLevel1TieShotPosition(shot, 1, position);
+	shot.length = MIN(0x1000, shot.length + 0x400);
+	shot.distance += 0x380;
+	hit = false;
+
+	if (!outsideView) {
+		if (position[2] > 0x280)
+			return true;
+		hit = position[0] * position[0] + position[1] * position[1] < 450000;
+		return false;
+	}
+
+	const int dx = position[0] - shipPosition[0];
+	const int dy = position[1] - shipPosition[1];
+	const int dz = position[2] - shipPosition[2];
+	if ((int)sqrt((double)dx * dx + (double)dy * dy + (double)dz * dz) < 500) {
+		hit = true;
+		return false;
+	}
+	return position[2] > 200;
+}
+
+void renderLevel1TieShots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &bolt,
+		const RA2PSXLevel1TieShot *shots) {
+	for (int i = 0; i < kLevel1ShotCount; ++i) {
+		if (!shots[i].active)
+			continue;
+		for (int index = 0; index < 2; ++index) {
+			int position[3];
+			getLevel1TieShotPosition(shots[i], index, position);
+			RA2PSXMatrix transform;
+			transform.setScale(0x1000, 0x1000, shots[i].length);
+			transform.preRotateY(shots[i].yaw[index]);
+			transform.preRotateX(shots[i].pitch[index]);
+			transform.setTranslation(position[0], position[1], position[2]);
+			renderer.renderTransformedModel(bolt, transform, false);
+		}
 	}
-	enemy.endY = (float)random.getRandomNumberRngSigned(-25, 245);
-	updateLevel1Enemy(enemy);
 }
 
-static void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
+void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 		int &directionX, int &directionY, bool left, bool right, bool up, bool down) {
 	if (left && right)
 		left = right = false;
@@ -240,7 +602,7 @@ static void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 	y = CLIP<int>(y + velocityY / 512, 48, 178);
 }
 
-static void updateLevel1Ship(RA2PSXLevel1Ship &ship,
+void updateLevel1Ship(RA2PSXLevel1Ship &ship,
 		bool left, bool right, bool up, bool down) {
 	if (left == right) {
 		ship.velocityX = ship.velocityX * 3 / 4;
@@ -262,17 +624,22 @@ static void updateLevel1Ship(RA2PSXLevel1Ship &ship,
 	ship.y = CLIP<int>(ship.y + ship.velocityY * 10 / 4096, -142, 157);
 }
 
-static void getLevel1ShipOrientation(const RA2PSXLevel1Ship &ship,
+// The nose follows both sticks, as the original's ship does: banking yaws it and
+// climbing pitches it, and the guns fire along that nose.
+void getLevel1ShipOrientation(const RA2PSXLevel1Ship &ship,
 		float &forwardX, float &forwardY, float &forwardZ, float &roll) {
 	const float bank = (ship.velocityX / 16) * 360.0f / 4096.0f;
+	const float climb = (ship.velocityY / 16) * 360.0f / 4096.0f;
 	const float yaw = -bank * 0.5f * 0.017453292519943295f;
-	forwardX = sinf(yaw);
-	forwardY = 0.0f;
-	forwardZ = -cosf(yaw);
+	const float pitch = climb * 0.5f * 0.017453292519943295f;
+	const float pitchCosine = cosf(pitch);
+	forwardX = sinf(yaw) * pitchCosine;
+	forwardY = -sinf(pitch);
+	forwardZ = -cosf(yaw) * pitchCosine;
 	roll = bank;
 }
 
-static void transformLevel1ShipPoint(const RA2PSXLevel1Ship &ship,
+void transformLevel1ShipPoint(const RA2PSXLevel1Ship &ship,
 		float localX, float localY, float localZ,
 		float &worldX, float &worldY, float &worldZ) {
 	float forwardX;
@@ -291,10 +658,17 @@ static void transformLevel1ShipPoint(const RA2PSXLevel1Ship &ship,
 			forwardZ * localZ;
 }
 
-static bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
-		const RA2PSXLevel1Ship *ship, int &targetScreenX, int &targetScreenY) {
+void traceLevel1Shot(RA2PSXLevel1Shot &shot) {
+	const float progress = shot.progress / 4096.0f;
+	shot.trace[0] = shot.traceStart[0] + (shot.targetX - shot.traceStart[0]) * progress;
+	shot.trace[1] = shot.traceStart[1] + (shot.targetY - shot.traceStart[1]) * progress;
+	shot.trace[2] = shot.traceStart[2] + (shot.targetZ - shot.traceStart[2]) * progress;
+}
+
+bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
+		int centerX, int centerY, int focal, const RA2PSXLevel1Ship *ship) {
 	int slot = -1;
-	for (int i = 0; i < 8; ++i) {
+	for (int i = 0; i < kLevel1ShotCount; ++i) {
 		if (!shots[i].active) {
 			slot = i;
 			break;
@@ -304,17 +678,23 @@ static bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
 		return false;
 
 	RA2PSXLevel1Shot &shot = shots[slot];
+	shot = RA2PSXLevel1Shot();
 	shot.active = true;
-	shot.progress = 400;
+	// The original launches from the muzzle in the cockpit and only skips ahead outside.
+	shot.progress = ship ? 400 : 0;
 	if (!ship) {
 		for (int i = 0; i < 3; ++i) {
 			for (int axis = 0; axis < 3; ++axis)
 				shot.start[i][axis] = kLevel1LaserStart[i][axis];
 			shot.roll[i] = kLevel1LaserRoll[i];
 		}
-		shot.targetX = (aimX - 160) * 18000.0f / 640.0f;
-		shot.targetY = (aimY - 120) * 18000.0f / 640.0f;
+		shot.targetX = (float)(aimX - centerX) * 18000.0f / focal;
+		shot.targetY = (float)(aimY - centerY) * 18000.0f / focal;
 		shot.targetZ = 18000.0f;
+		// Midway between the two cannon the original alternates between.
+		shot.traceStart[0] = 0.0f;
+		shot.traceStart[1] = 50.0f;
+		shot.traceStart[2] = 400.0f;
 	} else {
 		float forwardX;
 		float forwardY;
@@ -328,30 +708,50 @@ static bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
 			shot.roll[i] = kLevel1LaserRoll[i] + shipRoll;
 		}
 		transformLevel1ShipPoint(*ship, 0.0f, -70.0f, -100.0f,
-				shot.targetX, shot.targetY, shot.targetZ);
+				shot.traceStart[0], shot.traceStart[1], shot.traceStart[2]);
+		shot.targetX = shot.traceStart[0];
+		shot.targetY = shot.traceStart[1];
+		shot.targetZ = shot.traceStart[2];
 		shot.targetX -= forwardX * 18000.0f;
 		shot.targetY -= forwardY * 18000.0f;
 		shot.targetZ -= forwardZ * 18000.0f;
 	}
 
-	targetScreenX = 160 + (int)(shot.targetX * 640.0f / shot.targetZ);
-	targetScreenY = 120 + (int)(shot.targetY * 640.0f / shot.targetZ);
+	traceLevel1Shot(shot);
+	for (int axis = 0; axis < 3; ++axis)
+		shot.previousTrace[axis] = shot.trace[axis];
 	return true;
 }
 
-static void updateLevel1Shots(RA2PSXLevel1Shot *shots) {
-	for (int i = 0; i < 8; ++i) {
+void updateLevel1Shots(RA2PSXLevel1Shot *shots) {
+	for (int i = 0; i < kLevel1ShotCount; ++i) {
 		if (!shots[i].active)
 			continue;
+		for (int axis = 0; axis < 3; ++axis)
+			shots[i].previousTrace[axis] = shots[i].trace[axis];
 		shots[i].progress += 200;
 		if (shots[i].progress > 4399)
 			shots[i].active = false;
+		else
+			traceLevel1Shot(shots[i]);
 	}
 }
 
-static void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &laser,
+// ra2FindPlayerShotHit: once the bolt's midpoint is past the TIE it keeps testing every
+// frame, so aiming inside a TIE still scores as the bolt sweeps outwards.
+bool level1ShotHitsEnemy(const RA2PSXLevel1Shot &shot, const int *position) {
+	const float toPrevious = position[2] - shot.previousTrace[2];
+	const float toCurrent = position[2] - shot.trace[2];
+	if (toPrevious * toPrevious >= toCurrent * toCurrent)
+		return false;
+	const float dx = shot.trace[0] - position[0];
+	const float dy = shot.trace[1] - position[1];
+	return dx * dx + dy * dy <= (float)kLevel1HitRadius;
+}
+
+void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &laser,
 		const RA2PSXLevel1Shot *shots) {
-	for (int shotIndex = 0; shotIndex < 8; ++shotIndex) {
+	for (int shotIndex = 0; shotIndex < kLevel1ShotCount; ++shotIndex) {
 		const RA2PSXLevel1Shot &shot = shots[shotIndex];
 		if (!shot.active || shot.progress >= 4000)
 			continue;
@@ -370,18 +770,9 @@ static void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel
 	}
 }
 
-static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
-		const RA2PSXLevel1Enemy *enemies, const RA2PSXLevel1Explosion *explosions,
-		int laserTargetX, int laserTargetY) {
-	const uint32 green = surface.format.RGBToColor(64, 255, 96);
-
-	for (int i = 0; i < 3; ++i) {
-		if (enemies[i].active && enemies[i].laserFrames > 0) {
-			surface.drawLine((int)enemies[i].x - 2, (int)enemies[i].y,
-					laserTargetX - 15, laserTargetY, green);
-			surface.drawLine((int)enemies[i].x + 2, (int)enemies[i].y,
-					laserTargetX + 15, laserTargetY, green);
-		}
+void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
+		const RA2PSXLevel1Explosion *explosions) {
+	for (int i = 0; i < kLevel1EnemyCount; ++i) {
 		if (explosions[i].frames > 0)
 			ui.drawExplosion(surface, explosions[i].x, explosions[i].y,
 					10 - explosions[i].frames);
@@ -391,12 +782,14 @@ static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &
 
 Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		const RA2PSXModel &shipModel, const RA2PSXModel &crosshair,
-		const RA2PSXModel &laser, const RA2PSXLevel1UI &ui, int lives, int &score) {
+		const RA2PSXModel &laser, const RA2PSXModel &tieLaser,
+		const RA2PSXLevel1UI &ui, int lives, int &score) {
 #ifndef USE_TINYGL
 	(void)enemyModel;
 	(void)shipModel;
 	(void)crosshair;
 	(void)laser;
+	(void)tieLaser;
 	(void)ui;
 	(void)lives;
 	(void)score;
@@ -418,12 +811,18 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		return kLevel1Error;
 	}
 
-	RA2PSXLevel1Enemy enemies[3];
-	RA2PSXLevel1Explosion explosions[3];
-	RA2PSXLevel1Shot shots[8];
+	// The renderer's own projection, so aiming and the world agree.
+	const int centerX = _vm->_screenWidth / 2;
+	const int centerY = _vm->_screenHeight / 2;
+	const int focal = _vm->_screenWidth * 2;
+
+	RA2PSXLevel1Enemy enemies[kLevel1EnemyCount];
+	RA2PSXLevel1Explosion explosions[kLevel1EnemyCount];
+	RA2PSXLevel1Shot shots[kLevel1ShotCount];
+	RA2PSXLevel1TieShot tieShots[kLevel1ShotCount];
 	RA2PSXLevel1Ship ship;
 	RA2PSXSoundPlayer soundPlayer(_vm, _soundBank);
-	RA2PSXSoundPlayer::SoundId approachSounds[3] = {};
+	RA2PSXSoundPlayer::SoundId approachSounds[kLevel1EnemyCount] = {};
 	// A hard left/right pair for the cockpit and a centred one for outside,
 	// cross-faded as the view changes.
 	const RA2PSXSoundPlayer::SoundId engineLeft =
@@ -444,7 +843,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	int aimVelocityY = 0;
 	int aimDirectionX = 0;
 	int aimDirectionY = 0;
-	int shield = 100;
+	int shield = kLevel1ShieldFull;
 	int spawnDelay = 0;
 	int spawnRange = 80;
 	int spawnBase = 60;
@@ -679,7 +1078,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 
 			// Started once, then left running, as in the original.
-			if (shield <= 31 && lowShieldAlarm == RA2PSXSoundPlayer::kInvalidSoundId)
+			if (shield < kLevel1LowShield && lowShieldAlarm == RA2PSXSoundPlayer::kInvalidSoundId)
 				lowShieldAlarm = soundPlayer.play(kLevel1SfxLowShield, 0x50, 0x40);
 
 			if (logicFrame >= nextSpawnAdjustment) {
@@ -688,58 +1087,133 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				spawnBase = MAX(20, spawnBase - 1);
 			}
 
+			const int view = thirdPersonView ? 1 : 0;
+			const int shipPosition[3] = { ship.x, ship.y, ship.z };
+			// The cockpit aims from the camera, so the fire gate uses the origin there.
+			const int playerX = thirdPersonView ? ship.x : 0;
+			const int playerY = thirdPersonView ? ship.y : 0;
+
 			int activeEnemies = 0;
-			for (int i = 0; i < 3; ++i)
+			for (int i = 0; i < kLevel1EnemyCount; ++i)
 				activeEnemies += enemies[i].active ? 1 : 0;
 			--spawnDelay;
-			if (videoFrame < 1599 && activeEnemies < 3 && spawnDelay <= 0) {
-				int spawnedEnemy = -1;
-				for (int i = 0; i < 3; ++i) {
+			if (videoFrame < 1599 && activeEnemies < kLevel1EnemyCount && spawnDelay <= 0) {
+				for (int i = 0; i < kLevel1EnemyCount; ++i) {
 					if (!enemies[i].active) {
 						spawnLevel1Enemy(enemies[i], _vm->_rnd);
-						spawnedEnemy = i;
 						break;
 					}
 				}
-				if (spawnedEnemy >= 0) {
-					const uint16 sfx = _vm->_rnd.getRandomNumber(999) < 800 ?
-							kLevel1SfxTieApproachA : kLevel1SfxTieApproachB;
-					const int pitch = 0x1c18 + _vm->_rnd.getRandomNumber(1999);
-					approachSounds[spawnedEnemy] = soundPlayer.play(sfx, 0x5e, 0x40, pitch);
-				}
 				spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
 			}
 
 			updateLevel1Shots(shots);
-			for (int i = 0; i < 3; ++i) {
+			for (int i = 0; i < kLevel1ShotCount; ++i) {
+				if (!tieShots[i].active)
+					continue;
+				bool hit = false;
+				if (!updateLevel1TieShot(tieShots[i], thirdPersonView, shipPosition, hit))
+					tieShots[i].active = false;
+				if (hit) {
+					shield = MAX(0, shield - kLevel1BoltDamage[view][difficulty]);
+					soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
+				}
+			}
+
+			for (int i = 0; i < kLevel1EnemyCount; ++i) {
 				if (explosions[i].frames > 0)
 					--explosions[i].frames;
 				if (!enemies[i].active)
 					continue;
 
-				if (enemies[i].laserFrames > 0)
-					--enemies[i].laserFrames;
-				++enemies[i].age;
-				updateLevel1Enemy(enemies[i]);
-				const int soundPan = getLevel1SoundPan(enemies[i].x);
-				soundPlayer.setPan(approachSounds[i], soundPan);
-				if (enemies[i].age == enemies[i].fireFrame) {
-					enemies[i].laserFrames = 4;
-					soundPlayer.play(kLevel1SfxTieFire, 0x4e, soundPan);
-					if (_vm->_rnd.getRandomNumber(99) < 38) {
-						shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
-						soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
+				RA2PSXLevel1Enemy &enemy = enemies[i];
+				advanceSplineObject(enemy.lead);
+				advanceSplineObject(enemy.path);
+				orientTieAlongSpline(enemy, playerX, playerY);
+
+				const int *position = enemy.path.position;
+				const int soundPan = getLevel1WorldPan(position);
+				bool destroyed = false;
+
+				// Close in, a TIE that has not been shot down collides with the player.
+				if (position[2] <= 1499) {
+					bool rammed;
+					if (thirdPersonView) {
+						const int dx = position[0] - ship.x;
+						const int dy = position[1] - ship.y;
+						const int dz = position[2] - ship.z;
+						rammed = (int)sqrt((double)dx * dx + (double)dy * dy +
+								(double)dz * dz) < kLevel1RamRadius[1];
+					} else {
+						rammed = (int)sqrt((double)position[0] * position[0] +
+								(double)position[1] * position[1]) < kLevel1RamRadius[0];
+					}
+					if (rammed) {
+						shield = MAX(0, shield - kLevel1RamDamage[difficulty]);
+						destroyed = true;
 					}
 				}
-				if (enemies[i].age >= enemies[i].lifetime) {
+
+				if (!destroyed && position[2] < 0) {
+					soundPlayer.stop(approachSounds[i]);
+					approachSounds[i] = RA2PSXSoundPlayer::kInvalidSoundId;
+					enemy.active = false;
+					continue;
+				}
+
+				if (!destroyed) {
+					if (position[2] < 10000 &&
+							approachSounds[i] == RA2PSXSoundPlayer::kInvalidSoundId) {
+						const uint16 sfx = _vm->_rnd.getRandomNumber(999) < 800 ?
+								kLevel1SfxTieApproachA : kLevel1SfxTieApproachB;
+						const int pitch = 0x1c18 + _vm->_rnd.getRandomNumber(1999);
+						approachSounds[i] = soundPlayer.play(sfx, 0x5e, 0x40, pitch);
+					}
+					soundPlayer.setPan(approachSounds[i], soundPan);
+
+					for (int shotIndex = 0; shotIndex < kLevel1ShotCount && !destroyed; ++shotIndex) {
+						if (shots[shotIndex].active &&
+								level1ShotHitsEnemy(shots[shotIndex], position)) {
+							shots[shotIndex].active = false;
+							destroyed = true;
+							score = MIN(9999999, score + kLevel1KillScore[difficulty]);
+							if (score >= nextExtraLife) {
+								// The original also awards a life; the shared runner owns it.
+								soundPlayer.play(kLevel1SfxExtraLife, 0x7f, 0x40);
+								extraLifeStage = MIN(extraLifeStage + 1, 2);
+								nextExtraLife += kLevel1ExtraLife[difficulty][extraLifeStage];
+							}
+						}
+					}
+				}
+
+				// A TIE only shoots while it is still out at range and running square at
+				// the player; most shots are sprayed ahead rather than aimed.
+				if (!destroyed && position[2] < 16000 && --enemy.fireCountdown < 0 &&
+						enemy.facing >= kLevel1FireFacing && position[2] >= 2000) {
+					enemy.fireCountdown = (int)_vm->_rnd.getRandomNumber(44) + 8;
+					const bool aimed = (int)_vm->_rnd.getRandomNumber(99) <
+							kLevel1AimChance[view][difficulty];
+					if (spawnLevel1TieShot(tieShots, enemy, aimed, thirdPersonView,
+							shipPosition, _vm->_rnd))
+						soundPlayer.play(kLevel1SfxTieFire, 0x4e, soundPan);
+				}
+
+				if (destroyed) {
 					const int pitch = _vm->_rnd.getRandomNumber(0x3fff);
 					soundPlayer.play(kLevel1SfxTieExplode, 0x5a, soundPan, pitch);
 					soundPlayer.stop(approachSounds[i]);
 					approachSounds[i] = RA2PSXSoundPlayer::kInvalidSoundId;
-					enemies[i].active = false;
-					if (_vm->_rnd.getRandomNumber(99) < 18) {
-						shield = MAX(0, shield - 12);
-						soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
+					enemy.active = false;
+					for (int slot = 0; slot < kLevel1EnemyCount; ++slot) {
+						if (explosions[slot].frames == 0) {
+							explosions[slot].frames = 10;
+							explosions[slot].x = centerX +
+									position[0] * focal / MAX(1, position[2]);
+							explosions[slot].y = centerY +
+									position[1] * focal / MAX(1, position[2]);
+							break;
+						}
 					}
 				}
 			}
@@ -750,51 +1224,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			fireWasPressed = heldFire;
 			const bool shootRequested = fireRequested || triggerShot;
 			fireRequested = false;
-			if (shootRequested) {
-				int shotTargetX;
-				int shotTargetY;
-				if (!spawnLevel1Shot(shots, aimX, aimY,
-						thirdPersonView ? &ship : nullptr, shotTargetX, shotTargetY))
-					continue;
+			if (shootRequested && spawnLevel1Shot(shots, aimX, aimY, centerX, centerY, focal,
+					thirdPersonView ? &ship : nullptr))
 				soundPlayer.play(kLevel1SfxPlayerFire, 0x3f, 0x40);
-				int hitEnemy = -1;
-				float hitDistance = 1000000.0f;
-				for (int i = 0; i < 3; ++i) {
-					if (!enemies[i].active)
-						continue;
-					const float dx = enemies[i].x - shotTargetX;
-					const float dy = enemies[i].y - shotTargetY;
-					const float distance = dx * dx + dy * dy;
-					const float radius = MAX(10.0f, enemies[i].size * 0.72f);
-					if (distance <= radius * radius && distance < hitDistance) {
-						hitEnemy = i;
-						hitDistance = distance;
-					}
-				}
-				if (hitEnemy >= 0) {
-					const int soundPan = getLevel1SoundPan(enemies[hitEnemy].x);
-					const int pitch = _vm->_rnd.getRandomNumber(0x3fff);
-					soundPlayer.play(kLevel1SfxTieExplode, 0x5a, soundPan, pitch);
-					soundPlayer.stop(approachSounds[hitEnemy]);
-					approachSounds[hitEnemy] = RA2PSXSoundPlayer::kInvalidSoundId;
-					enemies[hitEnemy].active = false;
-					score = MIN(9999999, score + kLevel1KillScore[difficulty]);
-					if (score >= nextExtraLife) {
-						// The original also awards a life; the shared runner owns it.
-						soundPlayer.play(kLevel1SfxExtraLife, 0x7f, 0x40);
-						extraLifeStage = MIN(extraLifeStage + 1, 2);
-						nextExtraLife += kLevel1ExtraLife[difficulty][extraLifeStage];
-					}
-					for (int i = 0; i < 3; ++i) {
-						if (explosions[i].frames == 0) {
-							explosions[i].frames = 10;
-							explosions[i].x = (int)enemies[hitEnemy].x;
-							explosions[i].y = (int)enemies[hitEnemy].y;
-							break;
-						}
-					}
-				}
-			}
 		}
 
 		if (shield <= 0) {
@@ -804,11 +1236,28 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 
 		if (background && redraw) {
 			renderer.beginFrame(*background);
-			for (int i = 0; i < 3; ++i) {
-				if (enemies[i].active)
-					renderer.renderModel(enemyModel, enemies[i].x, enemies[i].y, enemies[i].size,
-							enemies[i].pitch, enemies[i].yaw, enemies[i].roll);
+			// Everything shares one painter's pass, farthest first, as the original's
+			// ordering table does.
+			int order[kLevel1EnemyCount] = { 0, 1, 2 };
+			for (int i = 0; i < kLevel1EnemyCount; ++i) {
+				for (int j = i + 1; j < kLevel1EnemyCount; ++j) {
+					if (enemies[order[j]].path.position[2] > enemies[order[i]].path.position[2])
+						SWAP(order[i], order[j]);
+				}
+			}
+			for (int i = 0; i < kLevel1EnemyCount; ++i) {
+				const RA2PSXLevel1Enemy &enemy = enemies[order[i]];
+				if (!enemy.active)
+					continue;
+				RA2PSXMatrix transform;
+				transform.setRotationZ(enemy.rotation[2]);
+				transform.preRotateY(enemy.rotation[1]);
+				transform.preRotateX(enemy.rotation[0]);
+				transform.setTranslation(enemy.path.position[0], enemy.path.position[1],
+						enemy.path.position[2]);
+				renderer.renderTransformedModel(enemyModel, transform, false);
 			}
+			renderLevel1TieShots(renderer, tieLaser, tieShots);
 			renderLevel1Shots(renderer, laser, shots);
 			if (thirdPersonView) {
 				float forwardX;
@@ -824,12 +1273,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 			Graphics::Surface output;
 			renderer.finishFrame(output);
-			const int laserTargetX = thirdPersonView ? 160 + ship.x * 640 / ship.z : 160;
-			const int laserTargetY = thirdPersonView ? 120 + ship.y * 640 / ship.z : output.h - 1;
-			drawLevel1Effects(output, ui, enemies, explosions, laserTargetX, laserTargetY);
+			drawLevel1Effects(output, ui, explosions);
 			if (!thirdPersonView)
 				ui.drawCockpit(output);
-			ui.drawHUD(output, score, lives, shield, logicFrame);
+			ui.drawHUD(output, score, lives, shield * 100 / kLevel1ShieldFull, logicFrame);
 			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
 			g_system->updateScreen();
 		}
diff --git a/engines/scumm/insane/rebel2/psx/menu.cpp b/engines/scumm/insane/rebel2/psx/menu.cpp
index 15bc1601ffa..8f2e4608a05 100644
--- a/engines/scumm/insane/rebel2/psx/menu.cpp
+++ b/engines/scumm/insane/rebel2/psx/menu.cpp
@@ -35,7 +35,7 @@
 namespace Scumm {
 
 // The menus run one logic step per vertical blank.
-static const int kMenuFrameRate = 60;
+const int kMenuFrameRate = 60;
 
 // Sound effect ids the menus use.
 enum {
@@ -173,7 +173,7 @@ struct RA2PSXMenuEvents {
 	int mouseY;
 };
 
-static void pollMenuEvents(ScummEngine_v7 *vm, RA2PSXMenuEvents &events) {
+void pollMenuEvents(ScummEngine_v7 *vm, RA2PSXMenuEvents &events) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
 		switch (event.type) {
@@ -259,7 +259,7 @@ static void pollMenuEvents(ScummEngine_v7 *vm, RA2PSXMenuEvents &events) {
 	}
 }
 
-static int hitTestMenu(Common::Rect (*itemRect)(int), int count, const Graphics::Surface &surface,
+int hitTestMenu(Common::Rect (*itemRect)(int), int count, const Graphics::Surface &surface,
 		int x, int y) {
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2;
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index a88d3af4bb9..20b00ecca51 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -21,14 +21,14 @@
 
 namespace Scumm {
 
-static bool readU16(const Common::Array<byte> &data, uint32 offset, uint16 &value) {
+bool readU16(const Common::Array<byte> &data, uint32 offset, uint16 &value) {
 	if (offset + 2 > data.size())
 		return false;
 	value = READ_LE_UINT16(data.data() + offset);
 	return true;
 }
 
-static bool readU32(const Common::Array<byte> &data, uint32 offset, uint32 &value) {
+bool readU32(const Common::Array<byte> &data, uint32 offset, uint32 &value) {
 	if (offset + 4 > data.size())
 		return false;
 	value = READ_LE_UINT32(data.data() + offset);
@@ -36,7 +36,7 @@ static bool readU32(const Common::Array<byte> &data, uint32 offset, uint32 &valu
 }
 
 // One turn is 4096 PlayStation angle units.
-static const float kRA2PSXAngleScale = 0.0015339807878856412f;
+const float kRA2PSXAngleScale = 0.0015339807878856412f;
 
 void RA2PSXMatrix::setIdentity() {
 	for (uint row = 0; row < 3; ++row) {
@@ -83,7 +83,7 @@ void RA2PSXMatrix::setRotationZ(int angle) {
 	rotation[1][1] = cosine;
 }
 
-static void preMultiply(RA2PSXMatrix &matrix, const RA2PSXMatrix &left) {
+void preMultiply(RA2PSXMatrix &matrix, const RA2PSXMatrix &left) {
 	RA2PSXMatrix result;
 	for (uint row = 0; row < 3; ++row) {
 		for (uint column = 0; column < 3; ++column) {
@@ -486,7 +486,7 @@ void RA2PSXTinyGLRenderer::setFaceState(const RA2PSXModel &model, const RA2PSXFa
 	}
 }
 
-static int getRA2PSXDepthCue(float depth, float focalLength) {
+int getRA2PSXDepthCue(float depth, float focalLength) {
 	if (depth <= 0.0f)
 		return 4096;
 	const int quotient = MIN(0x1ffff, (int)(focalLength * 65536.0f / depth));
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index be7f11be0ff..b9375c2869d 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -49,9 +49,9 @@ public:
 
 	Level1Handler(Rebel2PSX &psx, const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
-			const RA2PSXLevel1UI &ui, int &score) :
+			const RA2PSXModel &tieLaser, const RA2PSXLevel1UI &ui, int &score) :
 		_psx(psx), _enemy(enemy), _ship(ship), _crosshair(crosshair), _laser(laser),
-		_ui(ui), _score(score), _error(kGameplayError) {
+		_tieLaser(tieLaser), _ui(ui), _score(score), _error(kGameplayError) {
 	}
 
 	bool shouldQuit() const override {
@@ -59,7 +59,8 @@ public:
 	}
 
 	Result playAttempt(int &lives) override {
-		switch (_psx.playLevel1(_enemy, _ship, _crosshair, _laser, _ui, lives, _score)) {
+		switch (_psx.playLevel1(_enemy, _ship, _crosshair, _laser, _tieLaser, _ui,
+				lives, _score)) {
 		case Rebel2PSX::kLevel1Quit:
 			return kQuit;
 		case Rebel2PSX::kLevel1Complete:
@@ -106,6 +107,7 @@ private:
 	const RA2PSXModel &_ship;
 	const RA2PSXModel &_crosshair;
 	const RA2PSXModel &_laser;
+	const RA2PSXModel &_tieLaser;
 	const RA2PSXLevel1UI &_ui;
 	int &_score;
 	Error _error;
@@ -252,7 +254,8 @@ bool Rebel2PSX::loadMovieTextAssets(RA2PSXMovieText &movieText) {
 }
 
 bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
-		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
+		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXModel &tieLaser,
+		RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -266,6 +269,7 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 	Common::Array<byte> shipData;
 	Common::Array<byte> crosshairData;
 	Common::Array<byte> laserData;
+	Common::Array<byte> tieLaserData;
 	Common::Array<byte> enemyTextureData;
 	Common::Array<byte> shipTextureData;
 	return archive.getMember("fOFS/TieFighter/main", enemyData) && enemy.load(enemyData) &&
@@ -274,6 +278,7 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			archive.getMember("tex/BWingCockp", shipTextureData) && ship.loadTextures(shipTextureData) &&
 			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
 			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
+			archive.getMember("fOFS/TieLaser", tieLaserData) && tieLaser.load(tieLaserData) &&
 			ui.load(archive);
 }
 
@@ -322,8 +327,9 @@ Common::Error Rebel2PSX::runGame() {
 	RA2PSXModel ship;
 	RA2PSXModel crosshair;
 	RA2PSXModel laser;
+	RA2PSXModel tieLaser;
 	RA2PSXLevel1UI ui;
-	if (!loadLevel1Assets(enemy, ship, crosshair, laser, ui))
+	if (!loadLevel1Assets(enemy, ship, crosshair, laser, tieLaser, ui))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
@@ -337,7 +343,7 @@ Common::Error Rebel2PSX::runGame() {
 
 	int lives = 3;
 	int score = 0;
-	Level1Handler handler(*this, enemy, ship, crosshair, laser, ui, score);
+	Level1Handler handler(*this, enemy, ship, crosshair, laser, tieLaser, ui, score);
 	if (runRebel2Level1(handler, lives) != Rebel2Level1Handler::kError)
 		return Common::kNoError;
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index e8dd3b72a4c..fb7f4041620 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -315,10 +315,11 @@ private:
 	bool loadGlobalAssets(RA2PSXMainMenuUI &menu);
 	bool loadMovieTextAssets(RA2PSXMovieText &movieText);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
-			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui);
+			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXModel &tieLaser,
+			RA2PSXLevel1UI &ui);
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
-			const RA2PSXLevel1UI &ui, int lives, int &score);
+			const RA2PSXModel &tieLaser, const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
 	RA2PSXSoundBank _soundBank;
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index e22b4617596..69eec1f35da 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -29,7 +29,7 @@ struct RA2PSXUIGradientStop {
 	RA2PSXUIColor color;
 };
 
-static RA2PSXUIColor interpolateColor(const RA2PSXUIColor &from,
+RA2PSXUIColor interpolateColor(const RA2PSXUIColor &from,
 		const RA2PSXUIColor &to, int value, int maximum) {
 	if (maximum <= 0)
 		return from;
@@ -40,7 +40,7 @@ static RA2PSXUIColor interpolateColor(const RA2PSXUIColor &from,
 	return color;
 }
 
-static RA2PSXUIColor shieldColor(const RA2PSXUIGradientStop *stops, uint count, int index) {
+RA2PSXUIColor shieldColor(const RA2PSXUIGradientStop *stops, uint count, int index) {
 	for (uint i = 1; i < count; ++i) {
 		if (index <= stops[i].index)
 			return interpolateColor(stops[i - 1].color, stops[i].color,
@@ -205,8 +205,8 @@ void drawRA2PSXMenuHints(Graphics::Surface &surface, const RA2PSXTextureSet &tex
 	textures.draw(surface, "SELECT", xOffset + 252, yOffset + 216, Common::Rect(0, 0, 56, 11));
 }
 
-static const char kSmallGlyphs[] = "abcdefghijklmnopqrstuvwxyz0123456789%-:.?+/C ";
-static const byte kSmallWidths[] = {
+const char kSmallGlyphs[] = "abcdefghijklmnopqrstuvwxyz0123456789%-:.?+/C ";
+const byte kSmallWidths[] = {
 	6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 8, 6, 6, 6,
 	6, 6, 6, 6, 6, 6, 8, 6, 7, 6, 6, 4, 6, 6, 6, 6,
 	6, 6, 6, 6, 6, 6, 2, 2, 6, 6, 6, 8, 2
@@ -214,7 +214,7 @@ static const byte kSmallWidths[] = {
 static_assert(ARRAYSIZE(kSmallGlyphs) == ARRAYSIZE(kSmallWidths) + 1,
 		"RA2 PSX glyph widths do not match the font map");
 
-static int findSmallGlyph(char character) {
+int findSmallGlyph(char character) {
 	for (uint i = 0; i < ARRAYSIZE(kSmallWidths); ++i) {
 		if (character == kSmallGlyphs[i])
 			return i;
@@ -274,27 +274,27 @@ struct RA2PSXMovieTextRecord {
 	const char *text;
 };
 
-static const byte kMovieBigAdvances[] = {
+const byte kMovieBigAdvances[] = {
 	8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 16, 8, 8, 8, 8,
 	8, 8, 8, 8, 8, 16, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
 	8, 8, 8, 8, 8, 16, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 16,
 	8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8
 };
 
-static const byte kMovieBigWidths[] = {
+const byte kMovieBigWidths[] = {
 	6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 10, 6, 6, 6, 6,
 	6, 6, 6, 6, 8, 14, 6, 6, 6, 4, 6, 6, 6, 6, 6, 6, 6,
 	6, 2, 4, 6, 2, 10, 6, 4, 6, 6, 6, 4, 6, 6, 6, 6, 10,
 	6, 6, 6, 8, 2, 6, 4, 6, 6, 6, 6, 6, 6, 6, 6, 8, 4
 };
 
-static const byte kMovieSmallWidths[] = {
+const byte kMovieSmallWidths[] = {
 	6, 6, 6, 6, 6, 6, 6, 6, 2, 6, 6, 6, 8, 6, 6, 6,
 	6, 6, 6, 6, 6, 6, 8, 6, 6, 6, 6, 4, 6, 6, 6, 6,
 	6, 6, 6, 6, 6, 6, 2, 2, 6, 2, 6, 10, 10, 10, 10, 2
 };
 
-static const byte kMovieTinyWidths[] = {
+const byte kMovieTinyWidths[] = {
 	10, 10, 10, 10, 10, 10, 12, 12, 4, 10, 10, 8, 14, 10, 12,
 	10, 12, 10, 10, 12, 12, 12, 14, 10, 12, 10, 8, 8, 8, 8,
 	8, 6, 8, 8, 4, 6, 8, 4, 12, 8, 8, 8, 8, 6, 8, 6, 8, 8,
@@ -303,21 +303,21 @@ static const byte kMovieTinyWidths[] = {
 	6, 6, 6, 6, 8, 6, 4, 6
 };
 
-static const RA2PSXMovieFont kMovieBigFont = {
+const RA2PSXMovieFont kMovieBigFont = {
 	"fNT24b",
 	"ABCDEFGHIJKLMN" "\x01" "OPQRSTUVWXYZ-" "\x01"
 	"abcdefghijklmn," "\x01" "opqrstuvwxyz%." "\x01" "0123456789? ",
 	kMovieBigWidths, kMovieBigAdvances, 0, 16, 16
 };
 
-static const RA2PSXMovieFont kMovieSmallFont = {
+const RA2PSXMovieFont kMovieSmallFont = {
 	"fNT24s",
 	"abcdefghijkl" "\x01" "mnopqrstuvwx" "\x01" "yz0123456789" "\x01"
 	"%-:.? /{}[]|",
 	kMovieSmallWidths, nullptr, 12, 10, 10
 };
 
-static const RA2PSXMovieFont kMovieTinyFont = {
+const RA2PSXMovieFont kMovieTinyFont = {
 	"fNT24t",
 	"ABCDEFGHIJ" "\x01" "KLMNOPQRST" "\x01" "UVWXYZabcd" "\x01"
 	"efghijklmn" "\x01" "opqrstuvwx" "\x01" "yz01234567" "\x01"
@@ -325,7 +325,7 @@ static const RA2PSXMovieFont kMovieTinyFont = {
 	kMovieTinyWidths, nullptr, 16, 16, 17
 };
 
-static const RA2PSXMovieTextRecord kOpeningText[] = {
+const RA2PSXMovieTextRecord kOpeningText[] = {
 	{ 720, 30, 1, 2, 40,   0,  65, "starring" },
 	{ 720, 30, 6, 129, 40, 70,  85, "Jamison Jones" },
 	{ 720, 30, 6, 129, 40, 250, 100, "Julie Eccles" },
@@ -383,12 +383,12 @@ static const RA2PSXMovieTextRecord kOpeningText[] = {
 	{ 1366, 30, 7, 129, 40,  0, 100, "George Lucas" }
 };
 
-static const RA2PSXMovieTextRecord kChapter1Text[] = {
+const RA2PSXMovieTextRecord kChapter1Text[] = {
 	{ 30, 80, 1, 1, 0, 0, 24, "chapter 1" },
 	{ 40, 70, 3, 128, 0, 0, 37, "The Dreighton Triangle" }
 };
 
-static bool findMovieGlyph(const RA2PSXMovieFont &font, char character,
+bool findMovieGlyph(const RA2PSXMovieFont &font, char character,
 		RA2PSXMovieGlyph &glyph) {
 	int metric = 0;
 	int row = 0;
@@ -411,7 +411,7 @@ static bool findMovieGlyph(const RA2PSXMovieFont &font, char character,
 	return false;
 }
 
-static const RA2PSXMovieFont &getMovieFont(byte style) {
+const RA2PSXMovieFont &getMovieFont(byte style) {
 	if (style == 2 || style == 3)
 		return kMovieBigFont;
 	if (style == 6 || style == 7)
@@ -419,7 +419,7 @@ static const RA2PSXMovieFont &getMovieFont(byte style) {
 	return kMovieSmallFont;
 }
 
-static int measureMovieText(const RA2PSXMovieFont &font, const char *text,
+int measureMovieText(const RA2PSXMovieFont &font, const char *text,
 		uint characters, int spacing) {
 	int width = 0;
 	uint drawn = 0;
@@ -449,14 +449,14 @@ void RA2PSXTextureSet::drawHeadline(Graphics::Surface &surface, const char *text
 	}
 }
 
-static int scaleMovieX(int x) {
+int scaleMovieX(int x) {
 	const int remainder = x % 3;
 	if (remainder)
 		x += 3 - remainder;
 	return x * 2 / 3;
 }
 
-static int findMovieFontArchive(const Common::Array<byte> &data) {
+int findMovieFontArchive(const Common::Array<byte> &data) {
 	for (uint offset = 0; offset + 40 <= data.size(); offset += 4) {
 		if (!memcmp(data.data() + offset, "fNT24s", 7) &&
 				!memcmp(data.data() + offset + 16, "fNT24b", 7) &&
@@ -593,8 +593,8 @@ Common::Rect RA2PSXMainMenuUI::itemRect(int item) {
 }
 
 // Row positions from the original widget tables.
-static const int16 kOptionRowY[] = { 70, 85, 100, 115, 130, 145, 160, 190 };
-static const int16 kSoundRowY[] = { 87, 102, 117, 132, 162 };
+const int16 kOptionRowY[] = { 70, 85, 100, 115, 130, 145, 160, 190 };
+const int16 kSoundRowY[] = { 87, 102, 117, 132, 162 };
 
 Common::Rect RA2PSXOptionsUI::mainItemRect(int item) {
 	const int y = kOptionRowY[CLIP<int>(item, 0, ARRAYSIZE(kOptionRowY) - 1)];
@@ -793,7 +793,7 @@ struct RA2PSXChapterEntry {
 	int16 barWidth;
 };
 
-static const RA2PSXChapterEntry kChapters[RA2PSXChapterSelectUI::kChapterCount] = {
+const RA2PSXChapterEntry kChapters[RA2PSXChapterSelectUI::kChapterCount] = {
 	{ "chapter 1:",  142, "the dreighton triangle",       52, 158 },
 	{ "chapter 2:",  110, "the corellia star",           110, 122 },
 	{ "chapter 3:",  140, "mining tunnels",              110, 100 },
diff --git a/engines/scumm/insane/rebel2/psx/video.cpp b/engines/scumm/insane/rebel2/psx/video.cpp
index f714b6c2f5a..826712933ea 100644
--- a/engines/scumm/insane/rebel2/psx/video.cpp
+++ b/engines/scumm/insane/rebel2/psx/video.cpp
@@ -26,11 +26,11 @@
 
 namespace Scumm {
 
-static const uint32 kRawSectorSize = 2352;
-static const uint32 kFrameDataOffset = 56;
-static const byte kSectorTypeMask = 0x0e;
-static const byte kDataSectorType = 0x08;
-static const byte kVideoSectorType = 0x02;
+const uint32 kRawSectorSize = 2352;
+const uint32 kFrameDataOffset = 56;
+const byte kSectorTypeMask = 0x0e;
+const byte kDataSectorType = 0x08;
+const byte kVideoSectorType = 0x02;
 
 class RA2PSXVideoReadStream final : public Common::SeekableReadStream {
 public:
diff --git a/engines/scumm/insane/rebel2/render.cpp b/engines/scumm/insane/rebel2/render.cpp
index d3353bd2482..660c5dd0c53 100644
--- a/engines/scumm/insane/rebel2/render.cpp
+++ b/engines/scumm/insane/rebel2/render.cpp
@@ -44,11 +44,11 @@ int getRebel2IndicatorScale(int width, int height) {
 	return (width >= 640 || height >= 400) ? 2 : 1;
 }
 
-static bool isValidEmbeddedFrame(const InsaneRebel2::EmbeddedSanFrame &frame) {
+bool isValidEmbeddedFrame(const InsaneRebel2::EmbeddedSanFrame &frame) {
 	return frame.valid && frame.pixels && frame.width > 0 && frame.height > 0;
 }
 
-static int countEmbeddedFramePixels(const InsaneRebel2::EmbeddedSanFrame &frame) {
+int countEmbeddedFramePixels(const InsaneRebel2::EmbeddedSanFrame &frame) {
 	if (!isValidEmbeddedFrame(frame))
 		return 0;
 
@@ -72,7 +72,7 @@ bool parseRebel2TextOverlayFormat(const char *&str, NutRenderer *&curFont, int &
 	return fontId == -2;
 }
 
-static Common::String getRebel2VisibleTextPrefix(const char *text, int visibleChars) {
+Common::String getRebel2VisibleTextPrefix(const char *text, int visibleChars) {
 	Common::String out;
 	if (!text || visibleChars <= 0)
 		return out;
@@ -102,7 +102,7 @@ static Common::String getRebel2VisibleTextPrefix(const char *text, int visibleCh
 	return out;
 }
 
-static const char *getRebel2LevelEndFallbackString(int id) {
+const char *getRebel2LevelEndFallbackString(int id) {
 	switch (id) {
 	case 190:
 		return "^f02^c244Chapter Complete\n^f01^c244Password: %s";
@@ -172,7 +172,7 @@ void drawHandler8PovOverlayText(const Rebel2FontSet &fontSet, byte *renderBitmap
 	drawRebel2String(fontSet, str, strlen(str), renderBitmap, clipRect, x, y, pitch, color, flags);
 }
 
-static void blitEmbeddedFrameRegion(byte *renderBitmap, int pitch, int clipWidth, int clipHeight,
+void blitEmbeddedFrameRegion(byte *renderBitmap, int pitch, int clipWidth, int clipHeight,
 		const InsaneRebel2::EmbeddedSanFrame &frame, int destX, int destY,
 		int srcX, int srcY, int drawWidth, int drawHeight) {
 	if (!renderBitmap || !isValidEmbeddedFrame(frame) || drawWidth <= 0 || drawHeight <= 0)
@@ -204,7 +204,7 @@ static void blitEmbeddedFrameRegion(byte *renderBitmap, int pitch, int clipWidth
 	}
 }
 
-static bool readEmbeddedSanChunkHeader(Common::SeekableReadStream &stream, int64 containerEnd, const char *context,
+bool readEmbeddedSanChunkHeader(Common::SeekableReadStream &stream, int64 containerEnd, const char *context,
 		uint32 &tag, uint32 &chunkSize, int64 &dataEnd, int64 &nextChunkPos) {
 	const int64 headerPos = stream.pos();
 	if (headerPos < 0 || headerPos + 8 > containerEnd)
@@ -1939,7 +1939,7 @@ void renderNutSpriteClipped(byte *dst, int pitch, int dstH,
 	}
 }
 
-static void renderNutSpriteScaledClipped(byte *dst, int pitch, int width, int height,
+void renderNutSpriteScaledClipped(byte *dst, int pitch, int width, int height,
 		int clipLeft, int clipTop, int clipRight, int clipBottom,
 		int x, int y, NutRenderer *nut, int spriteIdx, bool mirror, int scale, bool transparent231) {
 	if (!nut || spriteIdx < 0 || spriteIdx >= nut->getNumChars() || !dst)
@@ -3196,7 +3196,7 @@ void InsaneRebel2::renderHandler7FlySprite(byte *renderBitmap, int pitch, int wi
 	}
 }
 
-static Common::Point getHandler7SpriteDrawPoint(const Common::Point &base, NutRenderer *nut, int spriteIndex) {
+Common::Point getHandler7SpriteDrawPoint(const Common::Point &base, NutRenderer *nut, int spriteIndex) {
 	Common::Point point = base;
 	if (nut && spriteIndex >= 0 && spriteIndex < nut->getNumChars()) {
 		point.x += nut->getCharXOffset(spriteIndex);


Commit: 68f27cca0512c3f68c64efc4ae10b2cd8c3bbf6d
    https://github.com/scummvm/scummvm/commit/68f27cca0512c3f68c64efc4ae10b2cd8c3bbf6d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: added correct explosions and debris among others in L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 4102c542c9f..04d15e45dea 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -83,8 +83,11 @@ enum {
 	kLevel1LeadFrames = 30,
 	kLevel1EnemyCount = 3,
 	kLevel1ShotCount = 8,
-	kLevel1ShieldFull = 0x1000,
-	kLevel1LowShield = 0x501,
+	kLevel1ShieldFull = kRA2PSXShieldFull,
+	kLevel1LowShield = kRA2PSXLowShield,
+	// The gauge chases the real value by 40 a frame, and a hit shakes the view.
+	kLevel1ShieldStep = 0x28,
+	kLevel1ShakeFrames = 6,
 	kLevel1FireFacing = 0xf3c,
 	kLevel1HitRadius = 0xc000
 };
@@ -314,14 +317,218 @@ struct RA2PSXLevel1Enemy {
 	int fireCountdown;
 };
 
+// The fireball billboard is scaled about eight times its 68x56 texture, drifts toward
+// the camera and runs one frame per tick; only five may burn at once.
+enum {
+	kLevel1ExplosionSlots = 5,
+	kLevel1ExplosionScale = 0x7fff,
+	kLevel1ExplosionDrift = 0x32,
+	kLevel1DebrisCount = 16,
+	kLevel1DebrisLife = 30
+};
+
 struct RA2PSXLevel1Explosion {
-	RA2PSXLevel1Explosion() : frames(0), x(0), y(0) {}
+	RA2PSXLevel1Explosion() : frame(-1), rotation(0) {
+		position[0] = position[1] = position[2] = 0;
+	}
 
-	int frames;
-	int x;
-	int y;
+	int frame;
+	int rotation;
+	int position[3];
 };
 
+struct RA2PSXLevel1Debris {
+	RA2PSXLevel1Debris() : life(0), model(0) {
+		for (int axis = 0; axis < 3; ++axis)
+			position[axis] = velocity[axis] = rotation[axis] = spin[axis] = 0;
+	}
+
+	int life;
+	int model;
+	int position[3];
+	int velocity[3];
+	int rotation[3];
+	int spin[3];
+};
+
+void spawnLevel1Explosion(RA2PSXLevel1Explosion *explosions, const int *position,
+		Common::RandomSource &random) {
+	int slot = -1;
+	int oldest = -1;
+	for (int i = 0; i < kLevel1ExplosionSlots; ++i) {
+		if (explosions[i].frame < 0) {
+			slot = i;
+			break;
+		}
+		if (explosions[i].frame > oldest) {
+			oldest = explosions[i].frame;
+			slot = i;
+		}
+	}
+
+	RA2PSXLevel1Explosion &explosion = explosions[slot];
+	explosion.frame = 0;
+	explosion.rotation = (int)random.getRandomNumber(15) * 0x100;
+	for (int axis = 0; axis < 3; ++axis)
+		explosion.position[axis] = position[axis];
+}
+
+void updateLevel1Explosions(RA2PSXLevel1Explosion *explosions, int frameCount) {
+	for (int i = 0; i < kLevel1ExplosionSlots; ++i) {
+		if (explosions[i].frame < 0)
+			continue;
+		if (++explosions[i].frame >= frameCount) {
+			explosions[i].frame = -1;
+			continue;
+		}
+		explosions[i].position[2] -= kLevel1ExplosionDrift;
+	}
+}
+
+void renderLevel1Explosions(RA2PSXTinyGLRenderer &renderer,
+		const Common::Array<RA2PSXTexture> &frames, const RA2PSXLevel1Explosion *explosions) {
+	for (int i = 0; i < kLevel1ExplosionSlots; ++i) {
+		const RA2PSXLevel1Explosion &explosion = explosions[i];
+		if (explosion.frame < 0 || (uint)explosion.frame >= frames.size())
+			continue;
+		const RA2PSXTexture &frame = frames[explosion.frame];
+		const float scale = kLevel1ExplosionScale / 4096.0f;
+		renderer.renderSprite(frame, (float)explosion.position[0], (float)explosion.position[1],
+				(float)explosion.position[2], frame.width * 0.5f * scale,
+				frame.height * 0.5f * scale, explosion.rotation);
+	}
+}
+
+// Each shard gets a tumble and a shove back toward the camera; one spin axis is dropped
+// so the pieces do not all rotate the same way.
+int debrisImpulse(Common::RandomSource &random) {
+	const int magnitude = (int)random.getRandomNumber(31) + 16;
+	return random.getRandomBit() ? magnitude : -magnitude;
+}
+
+void spawnLevel1Debris(RA2PSXLevel1Debris *debris, const int *position,
+		const int *rotation, int count, int modelCount, Common::RandomSource &random) {
+	for (int piece = 0; piece < count; ++piece) {
+		int slot = -1;
+		for (int i = 0; i < kLevel1DebrisCount; ++i) {
+			if (!debris[i].life) {
+				slot = i;
+				break;
+			}
+		}
+		if (slot < 0)
+			return;
+
+		RA2PSXLevel1Debris &shard = debris[slot];
+		shard = RA2PSXLevel1Debris();
+		shard.life = kLevel1DebrisLife;
+		shard.model = modelCount ? (int)random.getRandomNumber(modelCount - 1) : 0;
+		for (int axis = 0; axis < 3; ++axis) {
+			shard.position[axis] = position[axis];
+			shard.rotation[axis] = rotation[axis];
+			shard.spin[axis] = debrisImpulse(random);
+		}
+		shard.velocity[0] = debrisImpulse(random);
+		shard.velocity[1] = debrisImpulse(random);
+		shard.velocity[2] = -((int)random.getRandomNumber(63) + 32);
+		shard.spin[(int)random.getRandomNumber(2)] = 0;
+	}
+}
+
+void updateLevel1Debris(RA2PSXLevel1Debris *debris) {
+	for (int i = 0; i < kLevel1DebrisCount; ++i) {
+		if (!debris[i].life)
+			continue;
+		for (int axis = 0; axis < 3; ++axis) {
+			debris[i].position[axis] += debris[i].velocity[axis];
+			debris[i].rotation[axis] = (int16)(debris[i].rotation[axis] + debris[i].spin[axis]);
+		}
+		if (--debris[i].life && debris[i].position[2] < 0)
+			debris[i].life = 0;
+	}
+}
+
+void renderLevel1Debris(RA2PSXTinyGLRenderer &renderer,
+		const Common::Array<RA2PSXModel> &models, const RA2PSXLevel1Debris *debris) {
+	if (models.empty())
+		return;
+	for (int i = 0; i < kLevel1DebrisCount; ++i) {
+		if (!debris[i].life)
+			continue;
+		RA2PSXMatrix transform;
+		transform.setRotationZ(debris[i].rotation[2]);
+		transform.preRotateY(debris[i].rotation[1]);
+		transform.preRotateX(debris[i].rotation[0]);
+		transform.setTranslation(debris[i].position[0], debris[i].position[1],
+				debris[i].position[2]);
+		renderer.renderTransformedModel(models[debris[i].model % models.size()], transform, false);
+	}
+}
+
+// The gameplay frames are wider and taller than the screen; the view slides around
+// inside them and tilts, following a twenty frame average of where the player is aiming.
+enum {
+	kLevel1ViewHistory = 20,
+	kLevel1ViewCenterX = 0x34,
+	kLevel1ViewCenterY = 0x13,
+	kLevel1ViewTiltBase = 0x10
+};
+
+struct RA2PSXLevel1ViewTracker {
+	RA2PSXLevel1ViewTracker() : next(0), filled(0) {
+		for (int i = 0; i < kLevel1ViewHistory; ++i)
+			historyX[i] = historyY[i] = 0;
+	}
+
+	void push(int x, int y) {
+		historyX[next] = x;
+		historyY[next] = y;
+		next = (next + 1) % kLevel1ViewHistory;
+		filled = MIN(filled + 1, (int)kLevel1ViewHistory);
+	}
+
+	void average(int &x, int &y) const {
+		int totalX = 0;
+		int totalY = 0;
+		for (int i = 0; i < kLevel1ViewHistory; ++i) {
+			totalX += historyX[i];
+			totalY += historyY[i];
+		}
+		x = totalX / kLevel1ViewHistory;
+		y = totalY / kLevel1ViewHistory;
+	}
+
+	int historyX[kLevel1ViewHistory];
+	int historyY[kLevel1ViewHistory];
+	int next;
+	int filled;
+};
+
+// The original scales by 0x400000 then shifts back down by 11, rounding away from zero.
+int scaleViewOffset(int value, int divisor) {
+	const int scaled = (int)((int64)value * 0x400000 / divisor);
+	return (scaled + (scaled < 0 ? -0x400 : 0x400)) >> 11;
+}
+
+void getLevel1BackgroundView(const RA2PSXLevel1ViewTracker &tracker,
+		const Graphics::Surface &background, int width, int height,
+		RA2PSXBackgroundView &view) {
+	int averageX = 0;
+	int averageY = 0;
+	tracker.average(averageX, averageY);
+
+	const int tilt = scaleViewOffset(averageX, 0x4f00);
+	view.tiltLeft = kLevel1ViewTiltBase - tilt;
+	view.tiltRight = kLevel1ViewTiltBase + tilt;
+	view.panX = kLevel1ViewCenterX + scaleViewOffset(averageX, 5220);
+	// The band sits eight rows below the top of the port's cropped frame.
+	view.panY = kLevel1ViewCenterY + scaleViewOffset(averageY, 0x1ce3) - 8;
+
+	const int slack = MAX(0, (int)background.h - height) - MAX(view.tiltLeft, view.tiltRight);
+	view.panX = CLIP(view.panX, 0, MAX(0, (int)background.w - width));
+	view.panY = CLIP(view.panY, 0, MAX(0, slack));
+}
+
 struct RA2PSXLevel1TieShot {
 	RA2PSXLevel1TieShot() : active(false), distance(0), length(0) {}
 
@@ -770,26 +977,20 @@ void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &laser,
 	}
 }
 
-void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
-		const RA2PSXLevel1Explosion *explosions) {
-	for (int i = 0; i < kLevel1EnemyCount; ++i) {
-		if (explosions[i].frames > 0)
-			ui.drawExplosion(surface, explosions[i].x, explosions[i].y,
-					10 - explosions[i].frames);
-	}
-}
 #endif
 
 Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		const RA2PSXModel &shipModel, const RA2PSXModel &crosshair,
 		const RA2PSXModel &laser, const RA2PSXModel &tieLaser,
-		const RA2PSXLevel1UI &ui, int lives, int &score) {
+		const Common::Array<RA2PSXModel> &debrisModels, const RA2PSXLevel1UI &ui,
+		int lives, int &score) {
 #ifndef USE_TINYGL
 	(void)enemyModel;
 	(void)shipModel;
 	(void)crosshair;
 	(void)laser;
 	(void)tieLaser;
+	(void)debrisModels;
 	(void)ui;
 	(void)lives;
 	(void)score;
@@ -817,7 +1018,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	const int focal = _vm->_screenWidth * 2;
 
 	RA2PSXLevel1Enemy enemies[kLevel1EnemyCount];
-	RA2PSXLevel1Explosion explosions[kLevel1EnemyCount];
+	RA2PSXLevel1Explosion explosions[kLevel1ExplosionSlots];
+	RA2PSXLevel1Debris debris[kLevel1DebrisCount];
+	RA2PSXLevel1ViewTracker viewTracker;
 	RA2PSXLevel1Shot shots[kLevel1ShotCount];
 	RA2PSXLevel1TieShot tieShots[kLevel1ShotCount];
 	RA2PSXLevel1Ship ship;
@@ -844,6 +1047,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	int aimDirectionX = 0;
 	int aimDirectionY = 0;
 	int shield = kLevel1ShieldFull;
+	int shieldDisplayed = kLevel1ShieldFull;
+	int shakeFrames = 0;
+	int shakeX = 0;
+	int shakeY = 0;
 	int spawnDelay = 0;
 	int spawnRange = 80;
 	int spawnBase = 60;
@@ -1077,8 +1284,23 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 						CLIP<int>(ship.x * 4096 / 0x479f + 64, 0, 127));
 			}
 
+			if (shieldDisplayed != shield) {
+				shieldDisplayed = shieldDisplayed < shield ?
+						MIN(shield, shieldDisplayed + kLevel1ShieldStep) :
+						MAX(shield, shieldDisplayed - kLevel1ShieldStep);
+				shieldDisplayed = MAX(1, shieldDisplayed);
+			}
+			if (shakeFrames > 0) {
+				--shakeFrames;
+				shakeX = (int)_vm->_rnd.getRandomNumber(3);
+				shakeY = (int)_vm->_rnd.getRandomNumber(3);
+			} else {
+				shakeX = shakeY = 0;
+			}
+
 			// Started once, then left running, as in the original.
-			if (shield < kLevel1LowShield && lowShieldAlarm == RA2PSXSoundPlayer::kInvalidSoundId)
+			if (shieldDisplayed < kLevel1LowShield &&
+					lowShieldAlarm == RA2PSXSoundPlayer::kInvalidSoundId)
 				lowShieldAlarm = soundPlayer.play(kLevel1SfxLowShield, 0x50, 0x40);
 
 			if (logicFrame >= nextSpawnAdjustment) {
@@ -1108,6 +1330,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 
 			updateLevel1Shots(shots);
+			updateLevel1Explosions(explosions, _explosionFrames.size());
+			updateLevel1Debris(debris);
+			viewTracker.push(thirdPersonView ? ship.x * focal / MAX(1, ship.z) : aimX - centerX,
+					thirdPersonView ? ship.y * focal / MAX(1, ship.z) : aimY - centerY);
 			for (int i = 0; i < kLevel1ShotCount; ++i) {
 				if (!tieShots[i].active)
 					continue;
@@ -1116,13 +1342,12 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					tieShots[i].active = false;
 				if (hit) {
 					shield = MAX(0, shield - kLevel1BoltDamage[view][difficulty]);
+					shakeFrames = kLevel1ShakeFrames;
 					soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
 				}
 			}
 
 			for (int i = 0; i < kLevel1EnemyCount; ++i) {
-				if (explosions[i].frames > 0)
-					--explosions[i].frames;
 				if (!enemies[i].active)
 					continue;
 
@@ -1150,6 +1375,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					}
 					if (rammed) {
 						shield = MAX(0, shield - kLevel1RamDamage[difficulty]);
+						shakeFrames = kLevel1ShakeFrames;
 						destroyed = true;
 					}
 				}
@@ -1205,16 +1431,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					soundPlayer.stop(approachSounds[i]);
 					approachSounds[i] = RA2PSXSoundPlayer::kInvalidSoundId;
 					enemy.active = false;
-					for (int slot = 0; slot < kLevel1EnemyCount; ++slot) {
-						if (explosions[slot].frames == 0) {
-							explosions[slot].frames = 10;
-							explosions[slot].x = centerX +
-									position[0] * focal / MAX(1, position[2]);
-							explosions[slot].y = centerY +
-									position[1] * focal / MAX(1, position[2]);
-							break;
-						}
-					}
+					spawnLevel1Explosion(explosions, position, _vm->_rnd);
+					spawnLevel1Debris(debris, position, enemy.rotation,
+							(int)_vm->_rnd.getRandomNumber(4) + 1, debrisModels.size(), _vm->_rnd);
 				}
 			}
 
@@ -1235,7 +1454,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		}
 
 		if (background && redraw) {
-			renderer.beginFrame(*background);
+			RA2PSXBackgroundView view;
+			getLevel1BackgroundView(viewTracker, *background, _vm->_screenWidth,
+					_vm->_screenHeight, view);
+			renderer.beginFrame(*background, view);
 			// Everything shares one painter's pass, farthest first, as the original's
 			// ordering table does.
 			int order[kLevel1EnemyCount] = { 0, 1, 2 };
@@ -1259,6 +1481,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 			renderLevel1TieShots(renderer, tieLaser, tieShots);
 			renderLevel1Shots(renderer, laser, shots);
+			renderLevel1Debris(renderer, debrisModels, debris);
+			renderLevel1Explosions(renderer, _explosionFrames, explosions);
 			if (thirdPersonView) {
 				float forwardX;
 				float forwardY;
@@ -1273,11 +1497,11 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 			Graphics::Surface output;
 			renderer.finishFrame(output);
-			drawLevel1Effects(output, ui, explosions);
 			if (!thirdPersonView)
 				ui.drawCockpit(output);
-			ui.drawHUD(output, score, lives, shield * 100 / kLevel1ShieldFull, logicFrame);
-			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
+			ui.drawHUD(output, score, lives, shieldDisplayed, logicFrame);
+			g_system->copyRectToScreen(output.getPixels(), output.pitch, shakeX, shakeY,
+					output.w - shakeX, output.h - shakeY);
 			g_system->updateScreen();
 		}
 		g_system->delayMillis(5);
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index 20b00ecca51..ecc6f02cba2 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -179,6 +179,45 @@ bool loadRA2PSXTextures(const Common::Array<byte> &data,
 	return textures.size() > initialCount;
 }
 
+bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
+		Common::Array<RA2PSXTexture> &frames) {
+	if (data.size() < 4 + 512 || !frameHeight)
+		return false;
+	const uint16 widthField = READ_LE_UINT16(data.data());
+	const uint16 width = (widthField & 0xff) ? widthField & 0xff : 256;
+	if (!(widthField & 0x100) || !width)
+		return false;
+
+	const uint32 paletteOffset = 4;
+	const uint32 pixelsOffset = paletteOffset + 512;
+	const uint32 frameBytes = (uint32)width * frameHeight;
+	const uint32 frameCount = (data.size() - pixelsOffset) / frameBytes;
+	const uint32 height = frameHeight;
+	if (!frameCount)
+		return false;
+
+	for (uint32 frame = 0; frame < frameCount; ++frame) {
+		RA2PSXTexture texture;
+		texture.width = width;
+		texture.height = (uint16)height;
+		texture.pixels.resize(frameBytes);
+		for (uint32 i = 0; i < frameBytes; ++i) {
+			const byte paletteIndex = data[pixelsOffset + frame * frameBytes + i];
+			const uint16 value = READ_LE_UINT16(data.data() + paletteOffset + paletteIndex * 2);
+			if (!value) {
+				texture.pixels[i] = 0;
+				continue;
+			}
+			const uint32 r = ((value & 0x1f) << 3) | ((value & 0x1f) >> 2);
+			const uint32 g = (((value >> 5) & 0x1f) << 3) | (((value >> 5) & 0x1f) >> 2);
+			const uint32 b = (((value >> 10) & 0x1f) << 3) | (((value >> 10) & 0x1f) >> 2);
+			texture.pixels[i] = 0x01000000 | (r << 16) | (g << 8) | b;
+		}
+		frames.push_back(texture);
+	}
+	return true;
+}
+
 RA2PSXModel::RA2PSXModel() : _radius(1.0f) {
 }
 
@@ -541,6 +580,14 @@ void RA2PSXTinyGLRenderer::setFaceColor(const RA2PSXFace &face, uint vertexIndex
 }
 
 void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background) {
+	RA2PSXBackgroundView view;
+	view.panX = (background.w - MIN<int>(background.w, _width)) / 2;
+	view.panY = (background.h - MIN<int>(background.h, _height)) / 2;
+	beginFrame(background, view);
+}
+
+void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background,
+		const RA2PSXBackgroundView &view) {
 	if (!_context)
 		return;
 	TinyGL::setContext(_context);
@@ -550,12 +597,28 @@ void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background) {
 	surface.fillRect(Common::Rect(surface.w, surface.h), 0);
 	const int width = MIN<int>(background.w, surface.w);
 	const int height = MIN<int>(background.h, surface.h);
-	const int sourceX = (background.w - width) / 2;
-	const int sourceY = (background.h - height) / 2;
 	const int destX = (surface.w - width) / 2;
 	const int destY = (surface.h - height) / 2;
-	surface.copyRectToSurface(background, destX, destY,
-			Common::Rect(sourceX, sourceY, sourceX + width, sourceY + height));
+	// The source row ramps from one screen edge to the other, which rolls the view.
+	// Columns that land on the same row are copied as one run, as the original does.
+	const int span = MAX(1, width - 1);
+	const int bytesPerPixel = background.format.bytesPerPixel;
+	int column = 0;
+	while (column < width) {
+		const int tilt = view.tiltLeft + (view.tiltRight - view.tiltLeft) * column / span;
+		int end = column + 1;
+		while (end < width &&
+				view.tiltLeft + (view.tiltRight - view.tiltLeft) * end / span == tilt)
+			++end;
+		const int sourceX = CLIP(view.panX + column, 0, background.w - 1);
+		const int run = MIN(end - column, background.w - sourceX);
+		for (int row = 0; row < height; ++row) {
+			const int sourceY = CLIP(view.panY + tilt + row, 0, background.h - 1);
+			memcpy(surface.getBasePtr(destX + column, destY + row),
+					background.getBasePtr(sourceX, sourceY), run * bytesPerPixel);
+		}
+		column = end;
+	}
 	tglClear(TGL_DEPTH_BUFFER_BIT);
 
 	tglMatrixMode(TGL_PROJECTION);
@@ -565,6 +628,47 @@ void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background) {
 	tglLoadIdentity();
 }
 
+void RA2PSXTinyGLRenderer::renderSprite(const RA2PSXTexture &texture, float x, float y, float z,
+		float halfWidth, float halfHeight, int rotation) {
+	if (!_context || z <= 1.0f || texture.pixels.empty())
+		return;
+	TinyGL::setContext(_context);
+
+	const float focalLength = _width * 2.0f;
+	const float centerX = _width * 0.5f + x * focalLength / z;
+	const float centerY = _height * 0.5f + y * focalLength / z;
+	const float scaleX = halfWidth * focalLength / z;
+	const float scaleY = halfHeight * focalLength / z;
+	const float angle = rotation * kRA2PSXAngleScale;
+	const float cosine = cosf(angle);
+	const float sine = sinf(angle);
+	const float cornerX[4] = { -scaleX, scaleX, -scaleX, scaleX };
+	const float cornerY[4] = { -scaleY, -scaleY, scaleY, scaleY };
+	const float u[4] = { 0.0f, 1.0f, 0.0f, 1.0f };
+	const float v[4] = { 0.0f, 0.0f, 1.0f, 1.0f };
+
+	tglDisable(TGL_DEPTH_TEST);
+	tglEnable(TGL_TEXTURE_2D);
+	tglEnable(TGL_ALPHA_TEST);
+	tglEnable(TGL_BLEND);
+	tglBindTexture(TGL_TEXTURE_2D, getTextureId(texture));
+	tglColor4ub(0xff, 0xff, 0xff, 0xff);
+	tglBegin(TGL_QUAD_STRIP);
+	for (uint corner = 0; corner < 4; ++corner) {
+		tglTexCoord2f(u[corner], v[corner]);
+		tglVertex3f(centerX + cornerX[corner] * cosine - cornerY[corner] * sine,
+				centerY + cornerX[corner] * sine + cornerY[corner] * cosine, 0.0f);
+	}
+	tglEnd();
+	tglDisable(TGL_BLEND);
+	tglDisable(TGL_ALPHA_TEST);
+	tglDisable(TGL_TEXTURE_2D);
+	tglEnable(TGL_DEPTH_TEST);
+	_activeTexture = nullptr;
+	_textureEnabled = false;
+	_blendEnabled = false;
+}
+
 void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float y, float size,
 		float pitch, float yaw, float roll, bool depthTest) {
 	if (!_context || model.vertices().empty())
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index b9375c2869d..081091b4c4e 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -49,9 +49,10 @@ public:
 
 	Level1Handler(Rebel2PSX &psx, const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
-			const RA2PSXModel &tieLaser, const RA2PSXLevel1UI &ui, int &score) :
+			const RA2PSXModel &tieLaser, const Common::Array<RA2PSXModel> &debris,
+			const RA2PSXLevel1UI &ui, int &score) :
 		_psx(psx), _enemy(enemy), _ship(ship), _crosshair(crosshair), _laser(laser),
-		_tieLaser(tieLaser), _ui(ui), _score(score), _error(kGameplayError) {
+		_tieLaser(tieLaser), _debris(debris), _ui(ui), _score(score), _error(kGameplayError) {
 	}
 
 	bool shouldQuit() const override {
@@ -59,8 +60,8 @@ public:
 	}
 
 	Result playAttempt(int &lives) override {
-		switch (_psx.playLevel1(_enemy, _ship, _crosshair, _laser, _tieLaser, _ui,
-				lives, _score)) {
+		switch (_psx.playLevel1(_enemy, _ship, _crosshair, _laser, _tieLaser, _debris,
+				_ui, lives, _score)) {
 		case Rebel2PSX::kLevel1Quit:
 			return kQuit;
 		case Rebel2PSX::kLevel1Complete:
@@ -108,11 +109,31 @@ private:
 	const RA2PSXModel &_crosshair;
 	const RA2PSXModel &_laser;
 	const RA2PSXModel &_tieLaser;
+	const Common::Array<RA2PSXModel> &_debris;
 	const RA2PSXLevel1UI &_ui;
 	int &_score;
 	Error _error;
 };
 
+// The TIE breaks into its own body and wing pieces, five of each.
+bool loadRA2PSXDebrisModels(const RA2PSXArchive &archive,
+		const Common::Array<byte> &textureData, Common::Array<RA2PSXModel> &models) {
+	static const char *const kParts[] = { "body", "wing" };
+	for (uint part = 0; part < ARRAYSIZE(kParts); ++part) {
+		for (int index = 1; index <= 5; ++index) {
+			const Common::String path = Common::String::format("fOFS/TieFighter/%s_%d",
+					kParts[part], index);
+			Common::Array<byte> data;
+			RA2PSXModel model;
+			if (!archive.getMember(path, data) || !model.load(data) ||
+					!model.loadTextures(textureData))
+				return false;
+			models.push_back(model);
+		}
+	}
+	return true;
+}
+
 Common::SeekableReadStream *Rebel2PSX::openResource(int number) {
 	const Common::Path path(Common::String::format("RESOURCE.%03d", number));
 	Common::File *file = new Common::File();
@@ -230,6 +251,7 @@ bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
 		return false;
 
 	Common::Array<byte> textureData;
+	Common::Array<byte> explosionData;
 	Common::Array<byte> logoData;
 	Common::Array<byte> cloakData;
 	Common::Array<byte> crestData;
@@ -238,7 +260,9 @@ bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
 			!_logoModel.loadTextures(textureData) ||
 			!archive.getMember("fOFS/Cloak", cloakData) || !_cloakModel.load(cloakData) ||
 			!_cloakModel.loadTextures(textureData) ||
-			!archive.getMember("fOFS/LogoDbl", crestData) || !_crestModel.load(crestData))
+			!archive.getMember("fOFS/LogoDbl", crestData) || !_crestModel.load(crestData) ||
+			!archive.getMember("bigEx", explosionData) ||
+			!loadRA2PSXSpriteAnimation(explosionData, kRA2PSXExplosionHeight, _explosionFrames))
 		return false;
 
 	Common::Array<byte> soundData;
@@ -255,7 +279,7 @@ bool Rebel2PSX::loadMovieTextAssets(RA2PSXMovieText &movieText) {
 
 bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXModel &tieLaser,
-		RA2PSXLevel1UI &ui) {
+		Common::Array<RA2PSXModel> &debris, RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -279,6 +303,7 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
 			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
 			archive.getMember("fOFS/TieLaser", tieLaserData) && tieLaser.load(tieLaserData) &&
+			loadRA2PSXDebrisModels(archive, enemyTextureData, debris) &&
 			ui.load(archive);
 }
 
@@ -328,8 +353,9 @@ Common::Error Rebel2PSX::runGame() {
 	RA2PSXModel crosshair;
 	RA2PSXModel laser;
 	RA2PSXModel tieLaser;
+	Common::Array<RA2PSXModel> debris;
 	RA2PSXLevel1UI ui;
-	if (!loadLevel1Assets(enemy, ship, crosshair, laser, tieLaser, ui))
+	if (!loadLevel1Assets(enemy, ship, crosshair, laser, tieLaser, debris, ui))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
@@ -343,7 +369,7 @@ Common::Error Rebel2PSX::runGame() {
 
 	int lives = 3;
 	int score = 0;
-	Level1Handler handler(*this, enemy, ship, crosshair, laser, tieLaser, ui, score);
+	Level1Handler handler(*this, enemy, ship, crosshair, laser, tieLaser, debris, ui, score);
 	if (runRebel2Level1(handler, lives) != Rebel2Level1Handler::kError)
 		return Common::kNoError;
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index fb7f4041620..361614b96fd 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -41,6 +41,11 @@ class RA2PSXMainMenuUI;
 class RA2PSXMovieText;
 class RA2PSXOptionsUI;
 class RA2PSXChapterSelectUI;
+class RA2PSXArchive;
+class RA2PSXModel;
+
+bool loadRA2PSXDebrisModels(const RA2PSXArchive &archive,
+		const Common::Array<byte> &textureData, Common::Array<RA2PSXModel> &models);
 
 enum RA2PSXMovieTextSequence {
 	kRA2PSXMovieTextNone,
@@ -202,6 +207,23 @@ struct RA2PSXTexture {
 bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		Common::Array<RA2PSXTexture> &textures);
 
+// The fireball frames are 68x56, the size the original's VRAM upload uses.
+enum { kRA2PSXExplosionHeight = 56 };
+
+// bigEx: a 4 byte format word, a 256 colour palette and equal sized 8 bit frames.
+bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
+		Common::Array<RA2PSXTexture> &frames);
+
+// How far the level 1 background is panned and tilted inside its oversized frame.
+struct RA2PSXBackgroundView {
+	RA2PSXBackgroundView() : panX(0), panY(0), tiltLeft(0), tiltRight(0) {}
+
+	int panX;
+	int panY;
+	int tiltLeft;
+	int tiltRight;
+};
+
 struct RA2PSXFace {
 	uint16 vertex[4];
 	int16 normalX[4];
@@ -246,6 +268,10 @@ public:
 
 	bool init(int width, int height);
 	void beginFrame(const Graphics::Surface &background);
+	void beginFrame(const Graphics::Surface &background, const RA2PSXBackgroundView &view);
+	// A camera facing quad, sized in world units like the original's explosion billboard.
+	void renderSprite(const RA2PSXTexture &texture, float x, float y, float z,
+			float halfWidth, float halfHeight, int rotation);
 	void renderModel(const RA2PSXModel &model, float x, float y, float size,
 			float pitch, float yaw, float roll, bool depthTest = true);
 	void renderPerspectiveModel(const RA2PSXModel &model, float x, float y, float z,
@@ -316,10 +342,11 @@ private:
 	bool loadMovieTextAssets(RA2PSXMovieText &movieText);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXModel &tieLaser,
-			RA2PSXLevel1UI &ui);
+			Common::Array<RA2PSXModel> &debris, RA2PSXLevel1UI &ui);
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
-			const RA2PSXModel &tieLaser, const RA2PSXLevel1UI &ui, int lives, int &score);
+			const RA2PSXModel &tieLaser, const Common::Array<RA2PSXModel> &debris,
+			const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
 	RA2PSXSoundBank _soundBank;
@@ -329,6 +356,8 @@ private:
 	RA2PSXModel _logoModel;
 	RA2PSXModel _cloakModel;
 	RA2PSXModel _crestModel;
+	// The 42 frame fireball the original streams from the bigEx resource.
+	Common::Array<RA2PSXTexture> _explosionFrames;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index 69eec1f35da..d88b4f07549 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -964,11 +964,12 @@ void RA2PSXLevel1UI::drawShield(Graphics::Surface &surface, int shield,
 		{ 0, { 226, 207, 0 } }, { 15, { 219, 24, 0 } }, { 31, { 139, 3, 0 } }
 	};
 
-	shield = CLIP<int>(shield, 0, 100);
-	const int width = shield * 68 / 100;
+	// The gauge runs on the original's 0x1000 scale: 68 pixels wide, 32 colour steps.
+	shield = CLIP<int>(shield, 0, kRA2PSXShieldFull);
+	const int width = shield * 0x44 >> 12;
 	if (!width)
 		return;
-	const int colorIndex = CLIP<int>(32 - shield * 32 / 100, 0, 31);
+	const int colorIndex = CLIP<int>(0x20 - (shield >> 7), 0, 31);
 	const RA2PSXUIColor tl = shieldColor(topLeft, ARRAYSIZE(topLeft), colorIndex);
 	const RA2PSXUIColor tr = shieldColor(topRight, ARRAYSIZE(topRight), colorIndex);
 	const RA2PSXUIColor bl = shieldColor(bottomLeft, ARRAYSIZE(bottomLeft), colorIndex);
@@ -1027,7 +1028,7 @@ void RA2PSXLevel1UI::drawHUD(Graphics::Surface &surface, int score, int lives,
 	_textures.draw(surface, "FONT8X9", xOffset + 283, yOffset + 29, Common::Rect(80, 0, 88, 9));
 
 	int shieldLabelBrightness = 0x5a;
-	if (shield <= 31) {
+	if (shield < kRA2PSXLowShield) {
 		const int phase = (MAX(frame, 0) / 2) % 14;
 		shieldLabelBrightness += phase < 7 ? -50 + phase * 10 : 20 - (phase - 7) * 10;
 	}
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 3733a144acc..f4dc6ca9aab 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -156,6 +156,12 @@ private:
 	const RA2PSXTextureSet &_textures;
 };
 
+// The shield gauge runs 0 to 0x1000, and warns below 0x501.
+enum {
+	kRA2PSXShieldFull = 0x1000,
+	kRA2PSXLowShield = 0x501
+};
+
 class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);


Commit: a5985cf827c287cb6c5b1fb9f9cef0c90e073556
    https://github.com/scummvm/scummvm/commit/a5985cf827c287cb6c5b1fb9f9cef0c90e073556
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: added smoke trails and other effects in L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 04d15e45dea..2722eb180f4 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -435,10 +435,80 @@ void spawnLevel1Debris(RA2PSXLevel1Debris *debris, const int *position,
 	}
 }
 
-void updateLevel1Debris(RA2PSXLevel1Debris *debris) {
+// Each shard drags a smoke trail: a SMALLEX cell that spins and dims three steps a
+// frame from the grey the original picks on spawn.
+enum {
+	kLevel1PuffCount = 48,
+	kLevel1PuffSpin = 0x10,
+	kLevel1PuffFade = 3,
+	kLevel1PuffInterval = 3,
+	kLevel1PuffHalfSize = 64
+};
+
+struct RA2PSXLevel1Puff {
+	RA2PSXLevel1Puff() : brightness(0), rotation(0), spin(0) {
+		position[0] = position[1] = position[2] = 0;
+	}
+
+	int brightness;
+	int rotation;
+	int spin;
+	int position[3];
+};
+
+void spawnLevel1Puff(RA2PSXLevel1Puff *puffs, const int *position,
+		Common::RandomSource &random) {
+	int slot = -1;
+	int dimmest = 0x7fffffff;
+	for (int i = 0; i < kLevel1PuffCount; ++i) {
+		if (!puffs[i].brightness) {
+			slot = i;
+			break;
+		}
+		if (puffs[i].brightness < dimmest) {
+			dimmest = puffs[i].brightness;
+			slot = i;
+		}
+	}
+
+	RA2PSXLevel1Puff &puff = puffs[slot];
+	puff.brightness = (int)random.getRandomNumber(31) + 0x60;
+	puff.rotation = (int)random.getRandomNumber(0xfff);
+	puff.spin = 0;
+	for (int axis = 0; axis < 3; ++axis)
+		puff.position[axis] = position[axis];
+}
+
+void updateLevel1Puffs(RA2PSXLevel1Puff *puffs) {
+	for (int i = 0; i < kLevel1PuffCount; ++i) {
+		if (!puffs[i].brightness)
+			continue;
+		puffs[i].spin += kLevel1PuffSpin;
+		puffs[i].brightness = MAX(0, puffs[i].brightness - kLevel1PuffFade);
+	}
+}
+
+void renderLevel1Puffs(RA2PSXTinyGLRenderer &renderer, const RA2PSXTexture &texture,
+		const RA2PSXLevel1Puff *puffs) {
+	if (texture.pixels.empty())
+		return;
+	for (int i = 0; i < kLevel1PuffCount; ++i) {
+		const RA2PSXLevel1Puff &puff = puffs[i];
+		if (!puff.brightness)
+			continue;
+		renderer.renderSprite(texture, (float)puff.position[0], (float)puff.position[1],
+				(float)puff.position[2], kLevel1PuffHalfSize, kLevel1PuffHalfSize,
+				(int16)(puff.rotation + puff.spin), puff.brightness);
+	}
+}
+
+void updateLevel1Debris(RA2PSXLevel1Debris *debris, RA2PSXLevel1Puff *puffs,
+		Common::RandomSource &random) {
 	for (int i = 0; i < kLevel1DebrisCount; ++i) {
 		if (!debris[i].life)
 			continue;
+		if (debris[i].life % kLevel1PuffInterval == 0)
+			spawnLevel1Puff(puffs, debris[i].position, random);
 		for (int axis = 0; axis < 3; ++axis) {
 			debris[i].position[axis] += debris[i].velocity[axis];
 			debris[i].rotation[axis] = (int16)(debris[i].rotation[axis] + debris[i].spin[axis]);
@@ -1020,6 +1090,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	RA2PSXLevel1Enemy enemies[kLevel1EnemyCount];
 	RA2PSXLevel1Explosion explosions[kLevel1ExplosionSlots];
 	RA2PSXLevel1Debris debris[kLevel1DebrisCount];
+	RA2PSXLevel1Puff puffs[kLevel1PuffCount];
 	RA2PSXLevel1ViewTracker viewTracker;
 	RA2PSXLevel1Shot shots[kLevel1ShotCount];
 	RA2PSXLevel1TieShot tieShots[kLevel1ShotCount];
@@ -1049,6 +1120,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	int shield = kLevel1ShieldFull;
 	int shieldDisplayed = kLevel1ShieldFull;
 	int shakeFrames = 0;
+	int flashFrame = -1;
 	int shakeX = 0;
 	int shakeY = 0;
 	int spawnDelay = 0;
@@ -1290,6 +1362,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 						MAX(shield, shieldDisplayed - kLevel1ShieldStep);
 				shieldDisplayed = MAX(1, shieldDisplayed);
 			}
+			if (flashFrame >= 0 && ++flashFrame >= kRA2PSXHitFlashFrames)
+				flashFrame = -1;
 			if (shakeFrames > 0) {
 				--shakeFrames;
 				shakeX = (int)_vm->_rnd.getRandomNumber(3);
@@ -1331,7 +1405,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 
 			updateLevel1Shots(shots);
 			updateLevel1Explosions(explosions, _explosionFrames.size());
-			updateLevel1Debris(debris);
+			updateLevel1Debris(debris, puffs, _vm->_rnd);
+			updateLevel1Puffs(puffs);
 			viewTracker.push(thirdPersonView ? ship.x * focal / MAX(1, ship.z) : aimX - centerX,
 					thirdPersonView ? ship.y * focal / MAX(1, ship.z) : aimY - centerY);
 			for (int i = 0; i < kLevel1ShotCount; ++i) {
@@ -1343,6 +1418,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				if (hit) {
 					shield = MAX(0, shield - kLevel1BoltDamage[view][difficulty]);
 					shakeFrames = kLevel1ShakeFrames;
+					flashFrame = 0;
 					soundPlayer.play(kLevel1SfxPlayerHit, 0x7f, 0x40);
 				}
 			}
@@ -1376,6 +1452,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					if (rammed) {
 						shield = MAX(0, shield - kLevel1RamDamage[difficulty]);
 						shakeFrames = kLevel1ShakeFrames;
+						flashFrame = 0;
 						destroyed = true;
 					}
 				}
@@ -1482,6 +1559,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			renderLevel1TieShots(renderer, tieLaser, tieShots);
 			renderLevel1Shots(renderer, laser, shots);
 			renderLevel1Debris(renderer, debrisModels, debris);
+			renderLevel1Puffs(renderer, _smokeTexture, puffs);
 			renderLevel1Explosions(renderer, _explosionFrames, explosions);
 			if (thirdPersonView) {
 				float forwardX;
@@ -1497,6 +1575,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 			Graphics::Surface output;
 			renderer.finishFrame(output);
+			drawRA2PSXHitFlash(output, flashFrame);
 			if (!thirdPersonView)
 				ui.drawCockpit(output);
 			ui.drawHUD(output, score, lives, shieldDisplayed, logicFrame);
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index ecc6f02cba2..f05fd4508be 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -629,7 +629,7 @@ void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background,
 }
 
 void RA2PSXTinyGLRenderer::renderSprite(const RA2PSXTexture &texture, float x, float y, float z,
-		float halfWidth, float halfHeight, int rotation) {
+		float halfWidth, float halfHeight, int rotation, int brightness) {
 	if (!_context || z <= 1.0f || texture.pixels.empty())
 		return;
 	TinyGL::setContext(_context);
@@ -652,7 +652,9 @@ void RA2PSXTinyGLRenderer::renderSprite(const RA2PSXTexture &texture, float x, f
 	tglEnable(TGL_ALPHA_TEST);
 	tglEnable(TGL_BLEND);
 	tglBindTexture(TGL_TEXTURE_2D, getTextureId(texture));
-	tglColor4ub(0xff, 0xff, 0xff, 0xff);
+	// Textured primitives treat 0x80 as neutral, so the tint doubles.
+	const byte tint = (byte)MIN(brightness * 2, 0xff);
+	tglColor4ub(tint, tint, tint, 0xff);
 	tglBegin(TGL_QUAD_STRIP);
 	for (uint corner = 0; corner < 4; ++corner) {
 		tglTexCoord2f(u[corner], v[corner]);
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 081091b4c4e..037111c074b 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -115,6 +115,28 @@ private:
 	Error _error;
 };
 
+// The smoke trail draws the third 16x16 cell of the SMALLEX strip.
+static bool extractRA2PSXSmokeCell(const Common::Array<byte> &sheet, RA2PSXTexture &smoke) {
+	Common::Array<RA2PSXTexture> textures;
+	if (!loadRA2PSXTextures(sheet, textures))
+		return false;
+	for (uint i = 0; i < textures.size(); ++i) {
+		const RA2PSXTexture &source = textures[i];
+		if (!source.name.equalsIgnoreCase("SMALLEX") || source.width < 48 || source.height < 16)
+			continue;
+		smoke.name = "SMOKE";
+		smoke.width = 16;
+		smoke.height = 16;
+		smoke.pixels.resize(16 * 16);
+		for (int y = 0; y < 16; ++y) {
+			for (int x = 0; x < 16; ++x)
+				smoke.pixels[y * 16 + x] = source.pixels[y * source.width + 32 + x];
+		}
+		return true;
+	}
+	return false;
+}
+
 // The TIE breaks into its own body and wing pieces, five of each.
 bool loadRA2PSXDebrisModels(const RA2PSXArchive &archive,
 		const Common::Array<byte> &textureData, Common::Array<RA2PSXModel> &models) {
@@ -296,6 +318,7 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 	Common::Array<byte> tieLaserData;
 	Common::Array<byte> enemyTextureData;
 	Common::Array<byte> shipTextureData;
+	Common::Array<byte> commonTextureData;
 	return archive.getMember("fOFS/TieFighter/main", enemyData) && enemy.load(enemyData) &&
 			archive.getMember("tex/Ties", enemyTextureData) && enemy.loadTextures(enemyTextureData) &&
 			archive.getMember("fOFS/Ship", shipData) && ship.load(shipData) &&
@@ -304,6 +327,8 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
 			archive.getMember("fOFS/TieLaser", tieLaserData) && tieLaser.load(tieLaserData) &&
 			loadRA2PSXDebrisModels(archive, enemyTextureData, debris) &&
+			archive.getMember("tex/Common", commonTextureData) &&
+			extractRA2PSXSmokeCell(commonTextureData, _smokeTexture) &&
 			ui.load(archive);
 }
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 361614b96fd..3a3ef0a5d1e 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -271,7 +271,7 @@ public:
 	void beginFrame(const Graphics::Surface &background, const RA2PSXBackgroundView &view);
 	// A camera facing quad, sized in world units like the original's explosion billboard.
 	void renderSprite(const RA2PSXTexture &texture, float x, float y, float z,
-			float halfWidth, float halfHeight, int rotation);
+			float halfWidth, float halfHeight, int rotation, int brightness = 0x80);
 	void renderModel(const RA2PSXModel &model, float x, float y, float size,
 			float pitch, float yaw, float roll, bool depthTest = true);
 	void renderPerspectiveModel(const RA2PSXModel &model, float x, float y, float z,
@@ -356,8 +356,10 @@ private:
 	RA2PSXModel _logoModel;
 	RA2PSXModel _cloakModel;
 	RA2PSXModel _crestModel;
-	// The 42 frame fireball the original streams from the bigEx resource.
+	// The 42 frame fireball the original streams from the bigEx resource, and the
+	// single SMALLEX cell it trails smoke with.
 	Common::Array<RA2PSXTexture> _explosionFrames;
+	RA2PSXTexture _smokeTexture;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index d88b4f07549..f84577ed499 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -934,6 +934,41 @@ bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
 	return true;
 }
 
+// The damage wash: a full screen Gouraud quad whose corners run through the five frame
+// CFlash table in the executable's own resource directory, green against orange.
+const byte kRA2PSXHitFlash[4][kRA2PSXHitFlashFrames][3] = {
+	{ {   0,  39,   0 }, {   0,  91,   0 }, {   0,  69,   0 }, {   0,  48,   0 }, {   0,  27,   0 } },
+	{ {  63,   0,   0 }, { 147,  23,   0 }, { 116,  19,   0 }, {  85,  15,   0 }, {  55,  11,   0 } },
+	{ {  55,  11,   0 }, {  85,  15,   0 }, { 116,  19,   0 }, { 147,  23,   0 }, {  63,   0,   0 } },
+	{ {   0,  27,   0 }, {   0,  48,   0 }, {   0,  69,   0 }, {   0,  91,   0 }, {   0,  39,   0 } }
+};
+
+void drawRA2PSXHitFlash(Graphics::Surface &surface, int frame) {
+	if (frame < 0 || frame >= kRA2PSXHitFlashFrames)
+		return;
+	const int lastX = MAX(1, surface.w - 1);
+	const int lastY = MAX(1, surface.h - 1);
+	for (int y = 0; y < surface.h; ++y) {
+		int left[3];
+		int right[3];
+		for (int channel = 0; channel < 3; ++channel) {
+			left[channel] = (kRA2PSXHitFlash[0][frame][channel] * (lastY - y) +
+					kRA2PSXHitFlash[2][frame][channel] * y) / lastY;
+			right[channel] = (kRA2PSXHitFlash[1][frame][channel] * (lastY - y) +
+					kRA2PSXHitFlash[3][frame][channel] * y) / lastY;
+		}
+		for (int x = 0; x < surface.w; ++x) {
+			byte r, g, b;
+			surface.format.colorToRGB(surface.getPixel(x, y), r, g, b);
+			const int addR = (left[0] * (lastX - x) + right[0] * x) / lastX;
+			const int addG = (left[1] * (lastX - x) + right[1] * x) / lastX;
+			const int addB = (left[2] * (lastX - x) + right[2] * x) / lastX;
+			surface.setPixel(x, y, surface.format.RGBToColor(
+					MIN(0xff, r + addR), MIN(0xff, g + addG), MIN(0xff, b + addB)));
+		}
+	}
+}
+
 void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface) const {
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2 + 120;
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index f4dc6ca9aab..97b28479e5e 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -156,6 +156,11 @@ private:
 	const RA2PSXTextureSet &_textures;
 };
 
+// A hit washes the screen for five frames.
+enum { kRA2PSXHitFlashFrames = 5 };
+
+void drawRA2PSXHitFlash(Graphics::Surface &surface, int frame);
+
 // The shield gauge runs 0 to 0x1000, and warns below 0x501.
 enum {
 	kRA2PSXShieldFull = 0x1000,


Commit: e182864bf7d9e0d2e9e15f0084bb9c1c7999e1c0
    https://github.com/scummvm/scummvm/commit/e182864bf7d9e0d2e9e15f0084bb9c1c7999e1c0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: added missing animation and improved controls for L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 2722eb180f4..c7ad2768c27 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -102,6 +102,30 @@ const int kLevel1AimChance[2][3] = { { 5, 5, 5 }, { 15, 20, 20 } };
 // A TIE that gets this close without being shot collides with the player.
 const int kLevel1RamRadius[2] = { 200, 220 };
 
+// ra2InitializeCrosshair's box and start point, in the original's 240 line frame.
+enum {
+	kLevel1AimLeft = 30,
+	kLevel1AimWidth = 260,
+	kLevel1AimTop = 48,
+	kLevel1AimHeight = 130,
+	kLevel1AimStartX = 160,
+	kLevel1AimStartY = 113
+};
+
+// Swapping views dollies the ship out to 1000 and back to 40 at 20 a tick while the
+// cockpit shell zooms away; the original locks the controls until it lands.
+enum {
+	kLevel1ViewCockpitZ = 40,
+	kLevel1ViewOutsideZ = 1000,
+	kLevel1ViewStep = 20,
+	kLevel1ViewRecentre = 10,
+	kLevel1ViewZoomOutZ = 400,
+	kLevel1ViewZoomInZ = 300,
+	kLevel1CockpitScaleStep = 0xe0,
+	kLevel1CockpitScaleMin = 0x400,
+	kLevel1CockpitScaleFull = 0x1000
+};
+
 // The original truncates its 1/4096 products toward zero.
 int fixedShift12(int value) {
 	return (value < 0 ? value + 0xfff : value) >> 12;
@@ -830,7 +854,8 @@ void renderLevel1TieShots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &bol
 }
 
 void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
-		int &directionX, int &directionY, bool left, bool right, bool up, bool down) {
+		int &directionX, int &directionY, bool left, bool right, bool up, bool down,
+		const Common::Rect &bounds) {
 	if (left && right)
 		left = right = false;
 	if (up && down)
@@ -855,7 +880,7 @@ void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 		if ((up || down) && ABS(velocityX) < ABS(velocityY) / 2)
 			velocityX = ABS(velocityY) * directionX / 2;
 	}
-	x = CLIP<int>(x + velocityX / 512, 30, 290);
+	x = CLIP<int>(x + velocityX / 512, bounds.left, bounds.right);
 
 	if (!up && !down) {
 		directionY = 0;
@@ -876,7 +901,7 @@ void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 		if ((left || right) && ABS(velocityX) / 2 > ABS(velocityY))
 			velocityY = ABS(velocityX) * directionY / 2;
 	}
-	y = CLIP<int>(y + velocityY / 512, 48, 178);
+	y = CLIP<int>(y + velocityY / 512, bounds.top, bounds.bottom);
 }
 
 void updateLevel1Ship(RA2PSXLevel1Ship &ship,
@@ -1111,8 +1136,14 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	const int difficulty = CLIP(_settings.difficulty, 0, 2);
 	int extraLifeStage = 0;
 	int nextExtraLife = kLevel1ExtraLife[difficulty][0];
-	int aimX = 160;
-	int aimY = 113;
+	// The crosshair box lives in the original's 240 line frame; the port crops 20 rows.
+	const int viewOffsetX = (_vm->_screenWidth - 320) / 2;
+	const int viewOffsetY = (_vm->_screenHeight - 240) / 2;
+	const Common::Rect aimBounds(kLevel1AimLeft + viewOffsetX, kLevel1AimTop + viewOffsetY,
+			kLevel1AimLeft + kLevel1AimWidth + viewOffsetX,
+			kLevel1AimTop + kLevel1AimHeight + viewOffsetY);
+	int aimX = kLevel1AimStartX + viewOffsetX;
+	int aimY = kLevel1AimStartY + viewOffsetY;
 	int aimVelocityX = 0;
 	int aimVelocityY = 0;
 	int aimDirectionX = 0;
@@ -1145,7 +1176,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	bool actionFire = false;
 	bool fireRequested = false;
 	bool fireWasPressed = false;
-	bool thirdPersonView = true;
+	bool thirdPersonView = false;
+	int viewTransition = 0;
+	int cockpitScale = kLevel1CockpitScaleFull;
+	bool showCockpit = true;
 	Level1Result result = kLevel1Complete;
 	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
 			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
@@ -1168,20 +1202,20 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
 				if (thirdPersonView) {
-					ship.x = CLIP<int>((event.mouse.x - 160) * ship.z / 640, -282, 282);
-					ship.y = CLIP<int>((event.mouse.y - 120) * ship.z / 640, -142, 157);
+					ship.x = CLIP<int>((event.mouse.x - centerX) * ship.z / focal, -282, 282);
+					ship.y = CLIP<int>((event.mouse.y - centerY) * ship.z / focal, -142, 157);
 					ship.velocityX = ship.velocityY = 0;
 				} else {
-					aimX = CLIP<int>(event.mouse.x, 30, 290);
-					aimY = CLIP<int>(event.mouse.y, 48, 178);
+					aimX = CLIP<int>(event.mouse.x, aimBounds.left, aimBounds.right);
+					aimY = CLIP<int>(event.mouse.y, aimBounds.top, aimBounds.bottom);
 					aimVelocityX = aimVelocityY = 0;
 					aimDirectionX = aimDirectionY = 0;
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN:
 				if (!thirdPersonView) {
-					aimX = CLIP<int>(event.mouse.x, 30, 290);
-					aimY = CLIP<int>(event.mouse.y, 48, 178);
+					aimX = CLIP<int>(event.mouse.x, aimBounds.left, aimBounds.right);
+					aimY = CLIP<int>(event.mouse.y, aimBounds.top, aimBounds.bottom);
 				}
 				mouseFire = true;
 				fireRequested = true;
@@ -1294,14 +1328,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				break;
 			}
 		}
-		if (toggleViewRequested) {
-			thirdPersonView = !thirdPersonView;
+		if (toggleViewRequested && !viewTransition) {
+			viewTransition = thirdPersonView ? -1 : 1;
 			ship.velocityX = ship.velocityY = 0;
-			aimX = 160;
-			aimY = 113;
 			aimVelocityX = aimVelocityY = 0;
 			aimDirectionX = aimDirectionY = 0;
-			g_system->warpMouse(160, thirdPersonView ? 120 : aimY);
+			if (viewTransition > 0)
+				ship.z = kLevel1ViewCockpitZ;
 			redraw = true;
 		}
 		if (result == kLevel1Quit)
@@ -1324,16 +1357,53 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		while (logicFrame < targetLogicFrame && shield > 0) {
 			++logicFrame;
 			redraw = true;
-			const bool left = moveLeft || actionLeft || joystickAxisX < -joystickDeadzone;
-			const bool right = moveRight || actionRight || joystickAxisX > joystickDeadzone;
-			const bool up = moveUp || actionUp || joystickAxisY < -joystickDeadzone;
-			const bool down = moveDown || actionDown || joystickAxisY > joystickDeadzone;
-			if (thirdPersonView) {
+			// Controls are dead while the camera moves, exactly as the original does.
+			const bool steering = viewTransition == 0;
+			const bool left = steering && (moveLeft || actionLeft ||
+					joystickAxisX < -joystickDeadzone);
+			const bool right = steering && (moveRight || actionRight ||
+					joystickAxisX > joystickDeadzone);
+			const bool up = steering && (moveUp || actionUp || joystickAxisY < -joystickDeadzone);
+			const bool down = steering && (moveDown || actionDown ||
+					joystickAxisY > joystickDeadzone);
+			if (viewTransition > 0) {
+				ship.z += kLevel1ViewStep;
+				// The original only keeps the shell on screen while it is still zooming.
+				showCockpit = ship.z <= kLevel1ViewZoomOutZ;
+				if (showCockpit)
+					cockpitScale = MAX<int>(kLevel1CockpitScaleMin,
+							cockpitScale - kLevel1CockpitScaleStep);
+				if (ship.z >= kLevel1ViewOutsideZ) {
+					ship.z = kLevel1ViewOutsideZ;
+					thirdPersonView = true;
+					viewTransition = 0;
+				}
+			} else if (viewTransition < 0) {
+				ship.z -= kLevel1ViewStep;
+				ship.x -= CLIP(ship.x, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
+				ship.y -= CLIP(ship.y, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
+				showCockpit = ship.z <= kLevel1ViewZoomInZ;
+				if (showCockpit) {
+					cockpitScale = MIN<int>(kLevel1CockpitScaleFull,
+							cockpitScale + kLevel1CockpitScaleStep);
+					if (cockpitScale == kLevel1CockpitScaleFull)
+						ship.z = kLevel1ViewCockpitZ;
+				}
+				if (ship.z <= kLevel1ViewCockpitZ) {
+					ship.z = kLevel1ViewOutsideZ;
+					cockpitScale = kLevel1CockpitScaleFull;
+					thirdPersonView = false;
+					viewTransition = 0;
+					showCockpit = true;
+					aimX = kLevel1AimStartX + viewOffsetX;
+					aimY = kLevel1AimStartY + viewOffsetY;
+				}
+			} else if (thirdPersonView) {
 				updateLevel1Ship(ship, left, right, up, down);
 			} else {
 				updateLevel1Aim(aimX, aimY, aimVelocityX, aimVelocityY,
 						aimDirectionX, aimDirectionY,
-						left, right, up, down);
+						left, right, up, down, aimBounds);
 			}
 
 			const int cockpitTarget = thirdPersonView ? 0 : kLevel1MixMaximum;
@@ -1561,7 +1631,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			renderLevel1Debris(renderer, debrisModels, debris);
 			renderLevel1Puffs(renderer, _smokeTexture, puffs);
 			renderLevel1Explosions(renderer, _explosionFrames, explosions);
-			if (thirdPersonView) {
+			if (thirdPersonView || viewTransition) {
 				float forwardX;
 				float forwardY;
 				float forwardZ;
@@ -1576,8 +1646,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			Graphics::Surface output;
 			renderer.finishFrame(output);
 			drawRA2PSXHitFlash(output, flashFrame);
-			if (!thirdPersonView)
-				ui.drawCockpit(output);
+			if (showCockpit && (!thirdPersonView || viewTransition))
+				ui.drawCockpit(output, cockpitScale);
 			ui.drawHUD(output, score, lives, shieldDisplayed, logicFrame);
 			g_system->copyRectToScreen(output.getPixels(), output.pitch, shakeX, shakeY,
 					output.w - shakeX, output.h - shakeY);
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index f84577ed499..69203cd0d2f 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -969,11 +969,38 @@ void drawRA2PSXHitFlash(Graphics::Surface &surface, int frame) {
 	}
 }
 
-void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface) const {
+void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface, int scale) const {
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2 + 120;
-	_textures.draw(surface, "COCKPITL", xOffset, yOffset, Common::Rect(0, 0, 224, 120));
-	_textures.draw(surface, "COCKPITR", xOffset + 224, yOffset, Common::Rect(0, 0, 120, 120));
+	if (scale >= 0x1000) {
+		_textures.draw(surface, "COCKPITL", xOffset, yOffset, Common::Rect(0, 0, 224, 120));
+		_textures.draw(surface, "COCKPITR", xOffset + 224, yOffset, Common::Rect(0, 0, 120, 120));
+		return;
+	}
+
+	// Draw the shell once, then rescale it about the screen centre.
+	Graphics::Surface shell;
+	shell.create(320, 120, surface.format);
+	_textures.draw(shell, "COCKPITL", 0, 0, Common::Rect(0, 0, 224, 120));
+	_textures.draw(shell, "COCKPITR", 224, 0, Common::Rect(0, 0, 120, 120));
+
+	const int centerX = surface.w / 2;
+	const int centerY = surface.h / 2;
+	const uint32 transparent = shell.format.RGBToColor(0, 0, 0);
+	for (int y = 0; y < surface.h; ++y) {
+		const int sourceY = ((y - centerY) * 0x1000 / scale) + centerY - yOffset;
+		if (sourceY < 0 || sourceY >= shell.h)
+			continue;
+		for (int x = 0; x < surface.w; ++x) {
+			const int sourceX = ((x - centerX) * 0x1000 / scale) + centerX - xOffset;
+			if (sourceX < 0 || sourceX >= shell.w)
+				continue;
+			const uint32 pixel = shell.getPixel(sourceX, sourceY);
+			if (pixel != transparent)
+				surface.setPixel(x, y, pixel);
+		}
+	}
+	shell.free();
 }
 
 void RA2PSXLevel1UI::drawExplosion(Graphics::Surface &surface, int x, int y, int frame) const {
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 97b28479e5e..5aead6cd965 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -171,7 +171,8 @@ class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);
 
-	void drawCockpit(Graphics::Surface &surface) const;
+	// The shell zooms away while the camera swaps views; 0x1000 is its resting size.
+	void drawCockpit(Graphics::Surface &surface, int scale = 0x1000) const;
 	void drawExplosion(Graphics::Surface &surface, int x, int y, int frame) const;
 	void drawHUD(Graphics::Surface &surface, int score, int lives, int shield, int frame) const;
 


Commit: b9d3731531c98b74db18d8fd795c4e062925a5fb
    https://github.com/scummvm/scummvm/commit/b9d3731531c98b74db18d8fd795c4e062925a5fb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: use the original's fire cadence and cockpit details for L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index c7ad2768c27..29832741102 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -89,9 +89,19 @@ enum {
 	kLevel1ShieldStep = 0x28,
 	kLevel1ShakeFrames = 6,
 	kLevel1FireFacing = 0xf3c,
-	kLevel1HitRadius = 0xc000
+	kLevel1HitRadius = 0xc000,
+	// ra2FirePlayerShot alternates between two diagonal cannon pairs on every trigger.
+	kLevel1BoltCount = 2
 };
 
+// Ticks the original insists on between single shots, and its auto fire repeat, indexed
+// by view.
+const int kLevel1FireGap[2] = { 4, 5 };
+const int kLevel1AutoFireRepeat[2] = { 12, 14 };
+// The cockpit shell drifts with the crosshair by these divisors.
+const int kLevel1CockpitDriftX = 0x628b;
+const int kLevel1CockpitDriftY = 0x3800;
+
 const int kLevel1SplineDepth[kLevel1SplinePoints] = { 17000, 14000, 11000, -4000, -8000 };
 
 // Bolt damage by view then difficulty, and the far heavier damage from a TIE that rams.
@@ -123,7 +133,8 @@ enum {
 	kLevel1ViewZoomInZ = 300,
 	kLevel1CockpitScaleStep = 0xe0,
 	kLevel1CockpitScaleMin = 0x400,
-	kLevel1CockpitScaleFull = 0x1000
+	kLevel1CockpitScaleFull = 0x1000,
+	kLevel1ShipSwellStep = 200
 };
 
 // The original truncates its 1/4096 products toward zero.
@@ -640,8 +651,8 @@ struct RA2PSXLevel1Shot {
 
 	bool active;
 	int progress;
-	float start[3][3];
-	float roll[3];
+	float start[kLevel1BoltCount][3];
+	float roll[kLevel1BoltCount];
 	float targetX;
 	float targetY;
 	float targetZ;
@@ -661,18 +672,16 @@ struct RA2PSXLevel1Ship {
 	int velocityY;
 };
 
-const float kLevel1LaserStart[3][3] = {
-	{ -350.0f, 200.0f, 400.0f },
-	{ 350.0f, 200.0f, 400.0f },
-	{ 0.0f, 500.0f, 400.0f }
+const float kLevel1LaserStart[2][kLevel1BoltCount][3] = {
+	{ { -600.0f, 200.0f, 400.0f }, { 600.0f, -100.0f, 400.0f } },
+	{ { -600.0f, -100.0f, 400.0f }, { 600.0f, 200.0f, 400.0f } }
 };
 
-const float kLevel1LaserRoll[3] = { -45.0f, 45.0f, 0.0f };
+const float kLevel1LaserRoll[kLevel1BoltCount] = { -45.0f, 45.0f };
 
-const float kLevel1ShipLaserStart[3][3] = {
+const float kLevel1ShipLaserStart[kLevel1BoltCount][3] = {
 	{ -93.0f, 11.0f, -139.0f },
-	{ 93.0f, 11.0f, -139.0f },
-	{ 4.0f, 210.0f, -111.0f }
+	{ 93.0f, 11.0f, -139.0f }
 };
 
 // The two cannon mounts, in the TIE's own space.
@@ -904,6 +913,58 @@ void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 	y = CLIP<int>(y + velocityY / 512, bounds.top, bounds.bottom);
 }
 
+// The camera swap. ra2UpdateExternalViewInput steps this twice per frame while it runs,
+// and keeps the pad dead throughout.
+struct RA2PSXLevel1ViewSwap {
+	RA2PSXLevel1ViewSwap() : direction(0), cockpitScale(kLevel1CockpitScaleFull),
+			shipScale(kLevel1CockpitScaleFull), showCockpit(true) {}
+
+	int direction;
+	int cockpitScale;
+	int shipScale;
+	bool showCockpit;
+};
+
+// Returns true once the swap lands, with outside telling the caller which view it landed in.
+bool stepLevel1ViewSwap(RA2PSXLevel1ViewSwap &swap, RA2PSXLevel1Ship &ship, bool &outside) {
+	if (swap.direction > 0) {
+		ship.z += kLevel1ViewStep;
+		swap.showCockpit = ship.z <= kLevel1ViewZoomOutZ;
+		if (swap.showCockpit)
+			swap.cockpitScale = MAX<int>(kLevel1CockpitScaleMin,
+					swap.cockpitScale - kLevel1CockpitScaleStep);
+		if (ship.z >= kLevel1ViewOutsideZ) {
+			ship.z = kLevel1ViewOutsideZ;
+			swap.direction = 0;
+			outside = true;
+			return true;
+		}
+		return false;
+	}
+
+	swap.shipScale += kLevel1ShipSwellStep;
+	ship.z -= kLevel1ViewStep;
+	ship.x -= CLIP(ship.x, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
+	ship.y -= CLIP(ship.y, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
+	swap.showCockpit = ship.z <= kLevel1ViewZoomInZ;
+	if (swap.showCockpit) {
+		swap.cockpitScale = MIN<int>(kLevel1CockpitScaleFull,
+				swap.cockpitScale + kLevel1CockpitScaleStep);
+		if (swap.cockpitScale == kLevel1CockpitScaleFull)
+			ship.z = kLevel1ViewCockpitZ;
+	}
+	if (ship.z <= kLevel1ViewCockpitZ) {
+		ship.z = kLevel1ViewOutsideZ;
+		swap.cockpitScale = kLevel1CockpitScaleFull;
+		swap.shipScale = kLevel1CockpitScaleFull;
+		swap.showCockpit = true;
+		swap.direction = 0;
+		outside = false;
+		return true;
+	}
+	return false;
+}
+
 void updateLevel1Ship(RA2PSXLevel1Ship &ship,
 		bool left, bool right, bool up, bool down) {
 	if (left == right) {
@@ -968,7 +1029,7 @@ void traceLevel1Shot(RA2PSXLevel1Shot &shot) {
 }
 
 bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
-		int centerX, int centerY, int focal, const RA2PSXLevel1Ship *ship) {
+		int centerX, int centerY, int focal, const RA2PSXLevel1Ship *ship, int pair) {
 	int slot = -1;
 	for (int i = 0; i < kLevel1ShotCount; ++i) {
 		if (!shots[i].active) {
@@ -985,9 +1046,9 @@ bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
 	// The original launches from the muzzle in the cockpit and only skips ahead outside.
 	shot.progress = ship ? 400 : 0;
 	if (!ship) {
-		for (int i = 0; i < 3; ++i) {
+		for (int i = 0; i < kLevel1BoltCount; ++i) {
 			for (int axis = 0; axis < 3; ++axis)
-				shot.start[i][axis] = kLevel1LaserStart[i][axis];
+				shot.start[i][axis] = kLevel1LaserStart[pair][i][axis];
 			shot.roll[i] = kLevel1LaserRoll[i];
 		}
 		shot.targetX = (float)(aimX - centerX) * 18000.0f / focal;
@@ -1003,7 +1064,7 @@ bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
 		float forwardZ;
 		float shipRoll;
 		getLevel1ShipOrientation(*ship, forwardX, forwardY, forwardZ, shipRoll);
-		for (int i = 0; i < 3; ++i) {
+		for (int i = 0; i < kLevel1BoltCount; ++i) {
 			transformLevel1ShipPoint(*ship, kLevel1ShipLaserStart[i][0],
 					kLevel1ShipLaserStart[i][1], kLevel1ShipLaserStart[i][2],
 					shot.start[i][0], shot.start[i][1], shot.start[i][2]);
@@ -1058,7 +1119,7 @@ void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &laser,
 		if (!shot.active || shot.progress >= 4000)
 			continue;
 		const float progress = shot.progress / 4096.0f;
-		for (int laserIndex = 0; laserIndex < 3; ++laserIndex) {
+		for (int laserIndex = 0; laserIndex < kLevel1BoltCount; ++laserIndex) {
 			const float *start = shot.start[laserIndex];
 			const float directionX = shot.targetX - start[0];
 			const float directionY = shot.targetY - start[1];
@@ -1078,7 +1139,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		const RA2PSXModel &shipModel, const RA2PSXModel &crosshair,
 		const RA2PSXModel &laser, const RA2PSXModel &tieLaser,
 		const Common::Array<RA2PSXModel> &debrisModels, const RA2PSXLevel1UI &ui,
-		int lives, int &score) {
+		int &lives, int &score) {
 #ifndef USE_TINYGL
 	(void)enemyModel;
 	(void)shipModel;
@@ -1160,7 +1221,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	int nextSpawnAdjustment = 0;
 	int logicFrame = -1;
 	int videoFrame = -1;
-	int16 rapidFireCounter = 0;
+	int fireTick = 0;
+	int lastFireTick = -kLevel1FireGap[1];
+	int autoFireCounter = 0;
+	int cannonPair = 0;
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
@@ -1177,9 +1241,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	bool fireRequested = false;
 	bool fireWasPressed = false;
 	bool thirdPersonView = false;
-	int viewTransition = 0;
-	int cockpitScale = kLevel1CockpitScaleFull;
-	bool showCockpit = true;
+	RA2PSXLevel1ViewSwap viewSwap;
 	Level1Result result = kLevel1Complete;
 	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
 			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
@@ -1328,12 +1390,12 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				break;
 			}
 		}
-		if (toggleViewRequested && !viewTransition) {
-			viewTransition = thirdPersonView ? -1 : 1;
+		if (toggleViewRequested && !viewSwap.direction) {
+			viewSwap.direction = thirdPersonView ? -1 : 1;
 			ship.velocityX = ship.velocityY = 0;
 			aimVelocityX = aimVelocityY = 0;
 			aimDirectionX = aimDirectionY = 0;
-			if (viewTransition > 0)
+			if (viewSwap.direction > 0)
 				ship.z = kLevel1ViewCockpitZ;
 			redraw = true;
 		}
@@ -1358,7 +1420,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			++logicFrame;
 			redraw = true;
 			// Controls are dead while the camera moves, exactly as the original does.
-			const bool steering = viewTransition == 0;
+			const bool steering = viewSwap.direction == 0;
 			const bool left = steering && (moveLeft || actionLeft ||
 					joystickAxisX < -joystickDeadzone);
 			const bool right = steering && (moveRight || actionRight ||
@@ -1366,37 +1428,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			const bool up = steering && (moveUp || actionUp || joystickAxisY < -joystickDeadzone);
 			const bool down = steering && (moveDown || actionDown ||
 					joystickAxisY > joystickDeadzone);
-			if (viewTransition > 0) {
-				ship.z += kLevel1ViewStep;
-				// The original only keeps the shell on screen while it is still zooming.
-				showCockpit = ship.z <= kLevel1ViewZoomOutZ;
-				if (showCockpit)
-					cockpitScale = MAX<int>(kLevel1CockpitScaleMin,
-							cockpitScale - kLevel1CockpitScaleStep);
-				if (ship.z >= kLevel1ViewOutsideZ) {
-					ship.z = kLevel1ViewOutsideZ;
-					thirdPersonView = true;
-					viewTransition = 0;
-				}
-			} else if (viewTransition < 0) {
-				ship.z -= kLevel1ViewStep;
-				ship.x -= CLIP(ship.x, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
-				ship.y -= CLIP(ship.y, -kLevel1ViewRecentre, (int)kLevel1ViewRecentre);
-				showCockpit = ship.z <= kLevel1ViewZoomInZ;
-				if (showCockpit) {
-					cockpitScale = MIN<int>(kLevel1CockpitScaleFull,
-							cockpitScale + kLevel1CockpitScaleStep);
-					if (cockpitScale == kLevel1CockpitScaleFull)
-						ship.z = kLevel1ViewCockpitZ;
-				}
-				if (ship.z <= kLevel1ViewCockpitZ) {
-					ship.z = kLevel1ViewOutsideZ;
-					cockpitScale = kLevel1CockpitScaleFull;
-					thirdPersonView = false;
-					viewTransition = 0;
-					showCockpit = true;
-					aimX = kLevel1AimStartX + viewOffsetX;
-					aimY = kLevel1AimStartY + viewOffsetY;
+			if (viewSwap.direction) {
+				for (int step = 0; step < 2 && viewSwap.direction; ++step) {
+					if (stepLevel1ViewSwap(viewSwap, ship, thirdPersonView) &&
+							!thirdPersonView) {
+						aimX = kLevel1AimStartX + viewOffsetX;
+						aimY = kLevel1AimStartY + viewOffsetY;
+					}
 				}
 			} else if (thirdPersonView) {
 				updateLevel1Ship(ship, left, right, up, down);
@@ -1551,7 +1589,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 							destroyed = true;
 							score = MIN(9999999, score + kLevel1KillScore[difficulty]);
 							if (score >= nextExtraLife) {
-								// The original also awards a life; the shared runner owns it.
+								++lives;
 								soundPlayer.play(kLevel1SfxExtraLife, 0x7f, 0x40);
 								extraLifeStage = MIN(extraLifeStage + 1, 2);
 								nextExtraLife += kLevel1ExtraLife[difficulty][extraLifeStage];
@@ -1584,15 +1622,28 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				}
 			}
 
-			const bool heldFire = mouseFire || keyFire || actionFire;
-			const bool triggerShot = updateRebel2Fire(heldFire, fireWasPressed,
-					rapidFire, false, rapidFireCounter);
+			// A press only counts once the minimum gap has passed; with auto fire the
+			// press just re-arms the repeat, which then fires on the same tick.
+			++fireTick;
+			const bool heldFire = steering && (mouseFire || keyFire || actionFire);
+			const bool fireEdge = heldFire && (fireRequested || !fireWasPressed);
 			fireWasPressed = heldFire;
-			const bool shootRequested = fireRequested || triggerShot;
 			fireRequested = false;
+			bool shootRequested = false;
+			if (fireEdge && fireTick - lastFireTick >= kLevel1FireGap[view]) {
+				lastFireTick = fireTick;
+				autoFireCounter = 0;
+				shootRequested = !rapidFire;
+			}
+			if (rapidFire && heldFire && --autoFireCounter < 0) {
+				autoFireCounter = kLevel1AutoFireRepeat[view];
+				shootRequested = true;
+			}
 			if (shootRequested && spawnLevel1Shot(shots, aimX, aimY, centerX, centerY, focal,
-					thirdPersonView ? &ship : nullptr))
+					thirdPersonView ? &ship : nullptr, cannonPair)) {
+				cannonPair ^= 1;
 				soundPlayer.play(kLevel1SfxPlayerFire, 0x3f, 0x40);
+			}
 		}
 
 		if (shield <= 0) {
@@ -1631,14 +1682,14 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			renderLevel1Debris(renderer, debrisModels, debris);
 			renderLevel1Puffs(renderer, _smokeTexture, puffs);
 			renderLevel1Explosions(renderer, _explosionFrames, explosions);
-			if (thirdPersonView || viewTransition) {
+			if (thirdPersonView || viewSwap.direction) {
 				float forwardX;
 				float forwardY;
 				float forwardZ;
 				float shipRoll;
 				getLevel1ShipOrientation(ship, forwardX, forwardY, forwardZ, shipRoll);
 				renderer.renderPerspectiveModel(shipModel, ship.x, ship.y, ship.z,
-						forwardX, forwardY, forwardZ, shipRoll, false);
+						forwardX, forwardY, forwardZ, shipRoll, false, viewSwap.shipScale);
 			} else {
 				renderer.renderModel(crosshair, aimX, aimY, 31.0f,
 						0.0f, 0.0f, 0.0f, false);
@@ -1646,8 +1697,12 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			Graphics::Surface output;
 			renderer.finishFrame(output);
 			drawRA2PSXHitFlash(output, flashFrame);
-			if (showCockpit && (!thirdPersonView || viewTransition))
-				ui.drawCockpit(output, cockpitScale);
+			if (viewSwap.showCockpit && (!thirdPersonView || viewSwap.direction))
+				ui.drawCockpit(output, viewSwap.cockpitScale,
+						scaleViewOffset(aimX - (kLevel1AimStartX + viewOffsetX),
+								kLevel1CockpitDriftX),
+						scaleViewOffset(aimY - (kLevel1AimStartY + viewOffsetY),
+								kLevel1CockpitDriftY));
 			ui.drawHUD(output, score, lives, shieldDisplayed, logicFrame);
 			g_system->copyRectToScreen(output.getPixels(), output.pitch, shakeX, shakeY,
 					output.w - shakeX, output.h - shakeY);
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index f05fd4508be..a94ae9b74a0 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -745,7 +745,7 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 
 void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 		float x, float y, float z, float directionX, float directionY, float directionZ,
-		float roll, bool depthTest) {
+		float roll, bool depthTest, int scale) {
 	if (!_context || model.vertices().empty())
 		return;
 
@@ -791,6 +791,13 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 	transform.rotation[2][0] = modelXz;
 	transform.rotation[2][1] = modelYz;
 	transform.rotation[2][2] = forwardZ;
+	if (scale != 0x1000) {
+		const float factor = scale / 4096.0f;
+		for (uint row = 0; row < 3; ++row) {
+			for (uint column = 0; column < 3; ++column)
+				transform.rotation[row][column] *= factor;
+		}
+	}
 	transform.translation[0] = x;
 	transform.translation[1] = y;
 	transform.translation[2] = z;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 3a3ef0a5d1e..4461a1ec4a7 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -276,7 +276,7 @@ public:
 			float pitch, float yaw, float roll, bool depthTest = true);
 	void renderPerspectiveModel(const RA2PSXModel &model, float x, float y, float z,
 			float directionX, float directionY, float directionZ, float roll,
-			bool depthTest = true);
+			bool depthTest = true, int scale = 0x1000);
 	void renderTransformedModel(const RA2PSXModel &model, const RA2PSXMatrix &transform,
 			bool depthTest = true);
 	void finishFrame(Graphics::Surface &surface);
@@ -346,7 +346,7 @@ private:
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
 			const RA2PSXModel &tieLaser, const Common::Array<RA2PSXModel> &debris,
-			const RA2PSXLevel1UI &ui, int lives, int &score);
+			const RA2PSXLevel1UI &ui, int &lives, int &score);
 
 	ScummEngine_v7 *_vm;
 	RA2PSXSoundBank _soundBank;
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index 69203cd0d2f..a3999ab76f9 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -969,9 +969,10 @@ void drawRA2PSXHitFlash(Graphics::Surface &surface, int frame) {
 	}
 }
 
-void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface, int scale) const {
-	const int xOffset = (surface.w - 320) / 2;
-	const int yOffset = (surface.h - 240) / 2 + 120;
+void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface, int scale,
+		int driftX, int driftY) const {
+	const int xOffset = (surface.w - 320) / 2 + driftX;
+	const int yOffset = (surface.h - 240) / 2 + 120 + driftY;
 	if (scale >= 0x1000) {
 		_textures.draw(surface, "COCKPITL", xOffset, yOffset, Common::Rect(0, 0, 224, 120));
 		_textures.draw(surface, "COCKPITR", xOffset + 224, yOffset, Common::Rect(0, 0, 120, 120));
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 5aead6cd965..44d45d9796a 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -171,8 +171,10 @@ class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);
 
-	// The shell zooms away while the camera swaps views; 0x1000 is its resting size.
-	void drawCockpit(Graphics::Surface &surface, int scale = 0x1000) const;
+	// The shell zooms away while the camera swaps views (0x1000 is its resting size)
+	// and drifts a few pixels with the crosshair.
+	void drawCockpit(Graphics::Surface &surface, int scale = 0x1000,
+			int driftX = 0, int driftY = 0) const;
 	void drawExplosion(Graphics::Surface &surface, int x, int y, int frame) const;
 	void drawHUD(Graphics::Surface &surface, int score, int lives, int shield, int frame) const;
 


Commit: d8041fdd6b8b6186660562bf13d72a7e8a9dafbe
    https://github.com/scummvm/scummvm/commit/d8041fdd6b8b6186660562bf13d72a7e8a9dafbe
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: pan the 3D scene with the view in L1 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.h


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 29832741102..0997a486e58 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -625,9 +625,12 @@ void getLevel1BackgroundView(const RA2PSXLevel1ViewTracker &tracker,
 	const int tilt = scaleViewOffset(averageX, 0x4f00);
 	view.tiltLeft = kLevel1ViewTiltBase - tilt;
 	view.tiltRight = kLevel1ViewTiltBase + tilt;
-	view.panX = kLevel1ViewCenterX + scaleViewOffset(averageX, 5220);
+	// ra2SetProjectionOffset gets the same figure negated, so the 3D scene rides the pan.
+	view.sceneX = -scaleViewOffset(averageX, 5220);
+	view.sceneY = -scaleViewOffset(averageY, 0x1ce3);
+	view.panX = kLevel1ViewCenterX - view.sceneX;
 	// The band sits eight rows below the top of the port's cropped frame.
-	view.panY = kLevel1ViewCenterY + scaleViewOffset(averageY, 0x1ce3) - 8;
+	view.panY = kLevel1ViewCenterY - view.sceneY - 8;
 
 	const int slack = MAX(0, (int)background.h - height) - MAX(view.tiltLeft, view.tiltRight);
 	view.panX = CLIP(view.panX, 0, MAX(0, (int)background.w - width));
@@ -1029,7 +1032,8 @@ void traceLevel1Shot(RA2PSXLevel1Shot &shot) {
 }
 
 bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
-		int centerX, int centerY, int focal, const RA2PSXLevel1Ship *ship, int pair) {
+		int centerX, int centerY, int focal, const RA2PSXLevel1Ship *ship, int pair,
+		int sceneX, int sceneY) {
 	int slot = -1;
 	for (int i = 0; i < kLevel1ShotCount; ++i) {
 		if (!shots[i].active) {
@@ -1051,8 +1055,8 @@ bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
 				shot.start[i][axis] = kLevel1LaserStart[pair][i][axis];
 			shot.roll[i] = kLevel1LaserRoll[i];
 		}
-		shot.targetX = (float)(aimX - centerX) * 18000.0f / focal;
-		shot.targetY = (float)(aimY - centerY) * 18000.0f / focal;
+		shot.targetX = (float)(aimX - centerX - sceneX) * 18000.0f / focal;
+		shot.targetY = (float)(aimY - centerY - sceneY) * 18000.0f / focal;
 		shot.targetZ = 18000.0f;
 		// Midway between the two cannon the original alternates between.
 		shot.traceStart[0] = 0.0f;
@@ -1242,6 +1246,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	bool fireWasPressed = false;
 	bool thirdPersonView = false;
 	RA2PSXLevel1ViewSwap viewSwap;
+	RA2PSXBackgroundView backgroundView;
 	Level1Result result = kLevel1Complete;
 	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
 			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
@@ -1517,6 +1522,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			updateLevel1Puffs(puffs);
 			viewTracker.push(thirdPersonView ? ship.x * focal / MAX(1, ship.z) : aimX - centerX,
 					thirdPersonView ? ship.y * focal / MAX(1, ship.z) : aimY - centerY);
+			if (background)
+				getLevel1BackgroundView(viewTracker, *background, _vm->_screenWidth,
+						_vm->_screenHeight, backgroundView);
 			for (int i = 0; i < kLevel1ShotCount; ++i) {
 				if (!tieShots[i].active)
 					continue;
@@ -1640,7 +1648,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				shootRequested = true;
 			}
 			if (shootRequested && spawnLevel1Shot(shots, aimX, aimY, centerX, centerY, focal,
-					thirdPersonView ? &ship : nullptr, cannonPair)) {
+					thirdPersonView ? &ship : nullptr, cannonPair,
+					backgroundView.sceneX, backgroundView.sceneY)) {
 				cannonPair ^= 1;
 				soundPlayer.play(kLevel1SfxPlayerFire, 0x3f, 0x40);
 			}
@@ -1652,10 +1661,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		}
 
 		if (background && redraw) {
-			RA2PSXBackgroundView view;
-			getLevel1BackgroundView(viewTracker, *background, _vm->_screenWidth,
-					_vm->_screenHeight, view);
-			renderer.beginFrame(*background, view);
+			renderer.setViewOffset(backgroundView.sceneX, backgroundView.sceneY);
+			renderer.beginFrame(*background, backgroundView);
 			// Everything shares one painter's pass, farthest first, as the original's
 			// ordering table does.
 			int order[kLevel1EnemyCount] = { 0, 1, 2 };
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index a94ae9b74a0..e29d8b55e80 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -434,7 +434,8 @@ bool RA2PSXModel::parseObject(const Common::Array<byte> &data, uint32 objectOffs
 #ifdef USE_TINYGL
 
 RA2PSXTinyGLRenderer::RA2PSXTinyGLRenderer() : _context(nullptr), _activeTexture(nullptr),
-		_textureEnabled(false), _blendEnabled(false), _width(0), _height(0) {
+		_textureEnabled(false), _blendEnabled(false), _width(0), _height(0),
+		_viewOffsetX(0), _viewOffsetY(0) {
 }
 
 RA2PSXTinyGLRenderer::~RA2PSXTinyGLRenderer() {
@@ -635,8 +636,8 @@ void RA2PSXTinyGLRenderer::renderSprite(const RA2PSXTexture &texture, float x, f
 	TinyGL::setContext(_context);
 
 	const float focalLength = _width * 2.0f;
-	const float centerX = _width * 0.5f + x * focalLength / z;
-	const float centerY = _height * 0.5f + y * focalLength / z;
+	const float centerX = _width * 0.5f + _viewOffsetX + x * focalLength / z;
+	const float centerY = _height * 0.5f + _viewOffsetY + y * focalLength / z;
 	const float scaleX = halfWidth * focalLength / z;
 	const float scaleY = halfHeight * focalLength / z;
 	const float angle = rotation * kRA2PSXAngleScale;
@@ -817,8 +818,8 @@ void RA2PSXTinyGLRenderer::renderTransformedModel(const RA2PSXModel &model,
 	};
 	Common::Array<ProjectedVertex> projected;
 	projected.resize(model.vertices().size());
-	const float centerX = _width * 0.5f;
-	const float centerY = _height * 0.5f;
+	const float centerX = _width * 0.5f + _viewOffsetX;
+	const float centerY = _height * 0.5f + _viewOffsetY;
 	const float focalLength = _width * 2.0f;
 	for (uint i = 0; i < model.vertices().size(); ++i) {
 		const RA2PSXVertex &vertex = model.vertices()[i];
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 4461a1ec4a7..405fc353427 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -214,14 +214,18 @@ enum { kRA2PSXExplosionHeight = 56 };
 bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
 		Common::Array<RA2PSXTexture> &frames);
 
-// How far the level 1 background is panned and tilted inside its oversized frame.
+// How far the level 1 background is panned and tilted inside its oversized frame, and the
+// matching shift the 3D scene rides along with.
 struct RA2PSXBackgroundView {
-	RA2PSXBackgroundView() : panX(0), panY(0), tiltLeft(0), tiltRight(0) {}
+	RA2PSXBackgroundView() : panX(0), panY(0), tiltLeft(0), tiltRight(0),
+			sceneX(0), sceneY(0) {}
 
 	int panX;
 	int panY;
 	int tiltLeft;
 	int tiltRight;
+	int sceneX;
+	int sceneY;
 };
 
 struct RA2PSXFace {
@@ -269,6 +273,8 @@ public:
 	bool init(int width, int height);
 	void beginFrame(const Graphics::Surface &background);
 	void beginFrame(const Graphics::Surface &background, const RA2PSXBackgroundView &view);
+	// Shifts every world space draw, the way the original moves the GTE screen offset.
+	void setViewOffset(int x, int y) { _viewOffsetX = x; _viewOffsetY = y; }
 	// A camera facing quad, sized in world units like the original's explosion billboard.
 	void renderSprite(const RA2PSXTexture &texture, float x, float y, float z,
 			float halfWidth, float halfHeight, int rotation, int brightness = 0x80);
@@ -299,6 +305,8 @@ private:
 	bool _blendEnabled;
 	int _width;
 	int _height;
+	int _viewOffsetX;
+	int _viewOffsetY;
 };
 #endif
 


Commit: 1872ad720dbcee441b8551e0fc9c50eb39fd343c
    https://github.com/scummvm/scummvm/commit/1872ad720dbcee441b8551e0fc9c50eb39fd343c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: sustain the looping sounds in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/audio.cpp


diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
index 39cba3ff0df..a207ca34a3b 100644
--- a/engines/scumm/insane/rebel2/psx/audio.cpp
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -107,6 +107,37 @@ struct RA2PSXPitch {
 
 typedef Common::SharedPtr<RA2PSXPitch> RA2PSXPitchRef;
 
+// A sample whose ADPCM blocks ask the voice to repeat plays until something stops it.
+class RA2PSXLoopStream : public Audio::RewindableAudioStream {
+public:
+	explicit RA2PSXLoopStream(Audio::RewindableAudioStream *stream) : _stream(stream) {}
+	~RA2PSXLoopStream() override { delete _stream; }
+
+	int readBuffer(int16 *buffer, const int numSamples) override {
+		int produced = 0;
+		while (produced < numSamples) {
+			const int read = _stream->readBuffer(buffer + produced, numSamples - produced);
+			if (read > 0) {
+				produced += read;
+				continue;
+			}
+			// An empty pass after rewinding means there is nothing to loop over.
+			if (!_stream->rewind() || _stream->readBuffer(buffer + produced, 1) <= 0)
+				break;
+			++produced;
+		}
+		return produced;
+	}
+
+	bool isStereo() const override { return _stream->isStereo(); }
+	int getRate() const override { return _stream->getRate(); }
+	bool endOfData() const override { return false; }
+	bool rewind() override { return _stream->rewind(); }
+
+private:
+	Audio::RewindableAudioStream *_stream;
+};
+
 class RA2PSXPitchStream : public Audio::RewindableAudioStream {
 public:
 	RA2PSXPitchStream(Audio::RewindableAudioStream *stream, const RA2PSXPitchRef &pitch)
@@ -365,9 +396,23 @@ Audio::RewindableAudioStream *RA2PSXSoundBank::makeStream(uint16 id, uint32 macr
 		return nullptr;
 	memcpy(copy, _data.data() + sample->offset, size);
 
+	// Every ADPCM block carries flags: bit 2 opens a loop, bit 1 asks the voice to repeat
+	// at the end. Nearly every sample points that at its own final block, which is just a
+	// sustain tail; only a genuine loop - the engine rumbles - opens earlier than that.
+	uint32 loopStart = size;
+	for (uint32 block = 0; block + 16 <= size; block += 16) {
+		if (copy[block + 1] & 0x04) {
+			loopStart = block;
+			break;
+		}
+	}
+	const bool loops = size >= 32 && (copy[size - 15] & 0x02) && loopStart + 16 < size;
+
 	Common::SeekableReadStream *source =
 			new Common::MemoryReadStream(copy, size, DisposeAfterUse::YES);
 	Audio::RewindableAudioStream *stream = Audio::makeXAStream(source, rate);
+	if (loops)
+		stream = new RA2PSXLoopStream(stream);
 	const ADSR *adsr = findADSR(adsrId);
 	if (adsr)
 		stream = new RA2PSXADSRStream(stream, adsr->attack, adsr->decay, adsr->sustain);
@@ -384,7 +429,7 @@ struct RA2PSXSoundPlayer::Impl {
 
 	struct Voice {
 		Voice() : active(false), waiting(false), waitForSampleEnd(false), macroDone(false),
-				root(false), sound(0), macro(0), step(0), adsr(0xffff), rate(0),
+				root(false), sound(0), macro(0), step(0), loopsLeft(-1), adsr(0xffff), rate(0),
 				sampleId(0), bendUp(2), bendDown(2),
 				pitch(RA2PSXSoundPlayer::kNeutralBend), volume(0), pan(64), priority(0),
 				born(0), readyTick(0),
@@ -398,6 +443,8 @@ struct RA2PSXSoundPlayer::Impl {
 		SoundId sound;
 		uint16 macro;
 		uint16 step;
+		// Countdown for macro op 5; negative means it has not been armed yet.
+		int loopsLeft;
 		uint16 adsr;
 		uint16 sampleId;
 		RA2PSXPitchRef pitchRef;
@@ -617,6 +664,18 @@ struct RA2PSXSoundPlayer::Impl {
 				}
 				break;
 			}
+			case 5:
+				// Jump back to a step, repeating the count in bytes 6-7; 0xffff never
+				// runs out. Only the low shield alarm uses it: 05 01 .. ff ff, so the
+				// beep-pause-beep body repeats for as long as the sound is held.
+				if (voice.loopsLeft == 0)
+					break;
+				if (voice.loopsLeft > 0)
+					--voice.loopsLeft;
+				else if (READ_LE_UINT16(command + 6) != 0xffff)
+					voice.loopsLeft = READ_LE_UINT16(command + 6);
+				voice.step = command[1];
+				break;
 			case 0xc:
 				voice.adsr = READ_LE_UINT16(command + 1);
 				break;


Commit: 22a782936cb79c755e80b7fccbb48493f30f1bfe
    https://github.com/scummvm/scummvm/commit/22a782936cb79c755e80b7fccbb48493f30f1bfe
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: emit the debris smoke trails as the original does in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level1.cpp


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 0997a486e58..1a7cc0ade3c 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -373,13 +373,14 @@ struct RA2PSXLevel1Explosion {
 };
 
 struct RA2PSXLevel1Debris {
-	RA2PSXLevel1Debris() : life(0), model(0) {
+	RA2PSXLevel1Debris() : life(0), model(0), puffCountdown(0) {
 		for (int axis = 0; axis < 3; ++axis)
 			position[axis] = velocity[axis] = rotation[axis] = spin[axis] = 0;
 	}
 
 	int life;
 	int model;
+	int puffCountdown;
 	int position[3];
 	int velocity[3];
 	int rotation[3];
@@ -470,25 +471,41 @@ void spawnLevel1Debris(RA2PSXLevel1Debris *debris, const int *position,
 	}
 }
 
-// Each shard drags a smoke trail: a SMALLEX cell that spins and dims three steps a
-// frame from the grey the original picks on spawn.
+void rotateVector(const RA2PSXMatrix &transform, const int *source, int *result) {
+	for (int row = 0; row < 3; ++row) {
+		float value = 0.0f;
+		for (int column = 0; column < 3; ++column)
+			value += transform.rotation[row][column] * source[column];
+		result[row] = (int)value;
+	}
+}
+
+// Each shard drags a smoke trail: a SMALLEX cell that spins and dims three steps a frame
+// from the grey the original picks on spawn. The emitter counts down 5 and is stepped
+// twice a frame, so a puff leaves every two and a half frames, and each one is thrown
+// along an upward base velocity turned through up to a quarter turn on all three axes.
 enum {
-	kLevel1PuffCount = 48,
+	kLevel1PuffCount = 64,
 	kLevel1PuffSpin = 0x10,
 	kLevel1PuffFade = 3,
-	kLevel1PuffInterval = 3,
-	kLevel1PuffHalfSize = 64
+	kLevel1PuffReload = 5,
+	kLevel1PuffStepsPerFrame = 2,
+	kLevel1PuffHalfSize = 64,
+	kLevel1PuffSpread = 0x400
 };
 
 struct RA2PSXLevel1Puff {
 	RA2PSXLevel1Puff() : brightness(0), rotation(0), spin(0) {
-		position[0] = position[1] = position[2] = 0;
+		for (int axis = 0; axis < 3; ++axis)
+			position[axis] = velocity[axis] = 0;
 	}
 
 	int brightness;
 	int rotation;
 	int spin;
+	// Kept in 1/4096 like the original's particles, so the drift stays smooth.
 	int position[3];
+	int velocity[3];
 };
 
 void spawnLevel1Puff(RA2PSXLevel1Puff *puffs, const int *position,
@@ -511,13 +528,28 @@ void spawnLevel1Puff(RA2PSXLevel1Puff *puffs, const int *position,
 	puff.rotation = (int)random.getRandomNumber(0xfff);
 	puff.spin = 0;
 	for (int axis = 0; axis < 3; ++axis)
-		puff.position[axis] = position[axis];
+		puff.position[axis] = position[axis] * 4096;
+
+	RA2PSXMatrix spin;
+	spin.preRotateX((int)random.getRandomNumber(kLevel1PuffSpread - 1) - kLevel1PuffSpread / 2);
+	spin.preRotateY((int)random.getRandomNumber(kLevel1PuffSpread - 1) - kLevel1PuffSpread / 2);
+	spin.preRotateZ((int)random.getRandomNumber(kLevel1PuffSpread - 1) - kLevel1PuffSpread / 2);
+	const int base[3] = {
+		(int)random.getRandomNumber(499) - 250,
+		-((int)random.getRandomNumber(299) + 0xb6a),
+		0
+	};
+	rotateVector(spin, base, puff.velocity);
+	for (int axis = 0; axis < 3; ++axis)
+		puff.velocity[axis] *= 16;
 }
 
 void updateLevel1Puffs(RA2PSXLevel1Puff *puffs) {
 	for (int i = 0; i < kLevel1PuffCount; ++i) {
 		if (!puffs[i].brightness)
 			continue;
+		for (int axis = 0; axis < 3; ++axis)
+			puffs[i].position[axis] += puffs[i].velocity[axis];
 		puffs[i].spin += kLevel1PuffSpin;
 		puffs[i].brightness = MAX(0, puffs[i].brightness - kLevel1PuffFade);
 	}
@@ -531,8 +563,8 @@ void renderLevel1Puffs(RA2PSXTinyGLRenderer &renderer, const RA2PSXTexture &text
 		const RA2PSXLevel1Puff &puff = puffs[i];
 		if (!puff.brightness)
 			continue;
-		renderer.renderSprite(texture, (float)puff.position[0], (float)puff.position[1],
-				(float)puff.position[2], kLevel1PuffHalfSize, kLevel1PuffHalfSize,
+		renderer.renderSprite(texture, puff.position[0] / 4096.0f, puff.position[1] / 4096.0f,
+				puff.position[2] / 4096.0f, kLevel1PuffHalfSize, kLevel1PuffHalfSize,
 				(int16)(puff.rotation + puff.spin), puff.brightness);
 	}
 }
@@ -542,8 +574,11 @@ void updateLevel1Debris(RA2PSXLevel1Debris *debris, RA2PSXLevel1Puff *puffs,
 	for (int i = 0; i < kLevel1DebrisCount; ++i) {
 		if (!debris[i].life)
 			continue;
-		if (debris[i].life % kLevel1PuffInterval == 0)
+		debris[i].puffCountdown -= kLevel1PuffStepsPerFrame;
+		while (debris[i].puffCountdown <= 0) {
+			debris[i].puffCountdown += kLevel1PuffReload;
 			spawnLevel1Puff(puffs, debris[i].position, random);
+		}
 		for (int axis = 0; axis < 3; ++axis) {
 			debris[i].position[axis] += debris[i].velocity[axis];
 			debris[i].rotation[axis] = (int16)(debris[i].rotation[axis] + debris[i].spin[axis]);
@@ -690,15 +725,6 @@ const float kLevel1ShipLaserStart[kLevel1BoltCount][3] = {
 // The two cannon mounts, in the TIE's own space.
 const int kLevel1TieMuzzle[2][3] = { { -60, 10, -50 }, { 60, 10, -50 } };
 
-void rotateVector(const RA2PSXMatrix &transform, const int *source, int *result) {
-	for (int row = 0; row < 3; ++row) {
-		float value = 0.0f;
-		for (int column = 0; column < 3; ++column)
-			value += transform.rotation[row][column] * source[column];
-		result[row] = (int)value;
-	}
-}
-
 void spawnLevel1Enemy(RA2PSXLevel1Enemy &enemy, Common::RandomSource &random) {
 	enemy = RA2PSXLevel1Enemy();
 	enemy.active = true;


Commit: 233d70d6b1eb107806ef4641bab8c6111b5ff982
    https://github.com/scummvm/scummvm/commit/233d70d6b1eb107806ef4641bab8c6111b5ff982
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: initial code for L2 in psx release

Changed paths:
  A engines/scumm/insane/rebel2/psx/level2.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.h
    engines/scumm/insane/rebel2/rebel.cpp
    engines/scumm/insane/rebel2/rebel.h
    engines/scumm/insane/rebel2/runlevels.cpp
    engines/scumm/insane/rebel2/shared.h
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
new file mode 100644
index 00000000000..24fb957cc25
--- /dev/null
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -0,0 +1,455 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "common/events.h"
+#include "common/system.h"
+#include "common/translation.h"
+#include "common/util.h"
+
+#include "graphics/cursorman.h"
+#include "graphics/surface.h"
+
+#include "scumm/scumm_v7.h"
+#include "scumm/insane/rebel2/shared.h"
+#include "scumm/insane/rebel2/psx/psx.h"
+#include "scumm/insane/rebel2/psx/ui.h"
+
+namespace Scumm {
+
+// Level 2 is a cover shooter, and the original runs its three parts as separate levels:
+// 2, 0xc9 and 0xca. The same engine drives chapters 11 and 12.
+const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
+	{ "l2P1bg", "r1L2P1", 0x26, 0x68, 0, 0x26, true,
+		0x2a, 0x32, 0xcf, 0x8d,
+		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{
+		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
+		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
+		{ 169, 73, 88, 108 }, { 167, 73, 90, 108 }, { 165, 73, 92, 108 },
+		{ 165, 73, 92, 108 }, { 179, 73, 78, 108 }, { 179, 73, 78, 108 },
+		{ 178, 73, 80, 108 }, { 174, 74, 84, 107 }, { 174, 74, 84, 107 },
+		{ 181, 73, 78, 109 }, { 181, 73, 78, 109 }, { 181, 73, 78, 108 },
+		{ 181, 73, 80, 108 }, { 181, 73, 80, 108 }, { 180, 73, 80, 108 },
+		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
+		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
+		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
+		} },
+	{ "l2P2bg", "r1L2P2", 0x00, 0x00, -10, 0x12, false,
+		0x1c, 0x26, 0x11e, 0xa0,
+		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{
+		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
+		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
+		{ 169, 73, 88, 108 }, { 167, 73, 90, 108 }, { 165, 73, 92, 108 },
+		{ 165, 73, 92, 108 }, { 179, 73, 78, 108 }, { 179, 73, 78, 108 },
+		{ 178, 73, 80, 108 }, { 174, 74, 84, 107 }, { 174, 74, 84, 107 },
+		{ 181, 73, 78, 109 }, { 181, 73, 78, 109 }, { 181, 73, 78, 108 },
+		{ 181, 73, 80, 108 }, { 181, 73, 80, 108 }, { 180, 73, 80, 108 },
+		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
+		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
+		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
+		} },
+	{ "l2P3bg", "r1L2P3", 0x00, 0x00, 0, 0x23, false,
+		0x1a, 0x43, 0xc9, 0x92,
+		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{
+		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
+		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
+		{ 169, 73, 88, 108 }, { 167, 73, 90, 108 }, { 165, 73, 92, 108 },
+		{ 165, 73, 92, 108 }, { 179, 73, 78, 108 }, { 179, 73, 78, 108 },
+		{ 178, 73, 80, 108 }, { 174, 74, 84, 107 }, { 174, 74, 84, 107 },
+		{ 181, 73, 78, 109 }, { 181, 73, 78, 109 }, { 181, 73, 78, 108 },
+		{ 181, 73, 80, 108 }, { 181, 73, 80, 108 }, { 180, 73, 80, 108 },
+		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
+		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
+		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
+		} }
+};
+
+RA2PSXLevel2Scene::RA2PSXLevel2Scene() : _part(0), _frame(0), _delay(0), _out(false),
+		_moving(false) {
+	for (int layer = 0; layer < 2; ++layer) {
+		for (int axis = 0; axis < 2; ++axis)
+			_scroll[layer][axis] = _scrollTarget[layer][axis] = _scrollStep[layer][axis] = 0;
+	}
+}
+
+const RA2PSXLevel2PartInfo &RA2PSXLevel2Scene::info() const {
+	return kRA2PSXLevel2Parts[_part];
+}
+
+bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part) {
+	if (part < 0 || part >= kRA2PSXLevel2PartCount)
+		return false;
+
+	Common::Array<byte> data;
+	_textures.clear();
+	if (!archive.getMember(kRA2PSXLevel2Parts[part].sheet, data) || !_textures.append(data))
+		return false;
+
+	static const char *const required[] = { "BACK_L", "BACK_R", "PARA_L", "PARA_R" };
+	for (uint i = 0; i < ARRAYSIZE(required); ++i) {
+		if (!_textures.has(required[i]))
+			return false;
+	}
+
+	// Every rookie pose is its own member: a format word, a palette and one image.
+	_rookie.clear();
+	for (int frame = 0; frame < kRA2PSXLevel2FrameCount; ++frame) {
+		const Common::String path = Common::String::format("%s/anim%02d",
+				kRA2PSXLevel2Parts[part].anims, frame);
+		Common::Array<byte> anim;
+		Common::Array<RA2PSXTexture> decoded;
+		if (!archive.getMember(path, anim) || anim.size() < 8)
+			return false;
+		const uint16 height = READ_LE_UINT16(anim.data() + 2) & 0xff;
+		if (!loadRA2PSXSpriteAnimation(anim, height ? height : 256, decoded) || decoded.empty())
+			return false;
+		_rookie.push_back(decoded[0]);
+	}
+
+	Common::Array<byte> hud;
+	_hud.clear();
+	if (archive.getMember("trpTex", hud))
+		_hud.append(hud);
+
+	_part = part;
+	_frame = 0;
+	_delay = 0;
+	_out = false;
+	_moving = false;
+	const RA2PSXLevel2PartInfo &part_ = kRA2PSXLevel2Parts[part];
+	_scroll[0][0] = _scrollTarget[0][0] = part_.backScroll << 16;
+	_scroll[1][0] = _scrollTarget[1][0] = part_.paraScroll << 16;
+	_scroll[0][1] = _scrollTarget[0][1] = 0;
+	_scroll[1][1] = _scrollTarget[1][1] = 0;
+	for (int layer = 0; layer < 2; ++layer) {
+		for (int axis = 0; axis < 2; ++axis)
+			_scrollStep[layer][axis] = 0;
+	}
+	return true;
+}
+
+void RA2PSXLevel2Scene::setScrollTarget(int backX, int backY, int paraX, int paraY) {
+	_scrollTarget[0][0] = backX;
+	_scrollTarget[0][1] = backY;
+	_scrollTarget[1][0] = paraX;
+	_scrollTarget[1][1] = paraY;
+	for (int layer = 0; layer < 2; ++layer) {
+		for (int axis = 0; axis < 2; ++axis)
+			_scrollStep[layer][axis] =
+					(_scrollTarget[layer][axis] - _scroll[layer][axis]) / kRA2PSXLevel2ScrollSteps;
+	}
+}
+
+void RA2PSXLevel2Scene::toggleCover() {
+	if (_moving)
+		return;
+	_moving = true;
+	if (_out) {
+		// Ducking back puts the walls where they started.
+		setScrollTarget(info().backScroll << 16, 0, info().paraScroll << 16, 0);
+	} else {
+		setScrollTarget(0, 0, 0, 0);
+	}
+}
+
+int RA2PSXLevel2Scene::aimFrame(int aimX, int aimY) const {
+	const RA2PSXLevel2PartInfo &part = info();
+	int column = 1;
+	while (column <= kRA2PSXLevel2AimColumns && part.aimColumns[column - 1] < aimX)
+		++column;
+	int row = 0;
+	while (row < kRA2PSXLevel2AimRows && part.aimRows[row] < aimY)
+		++row;
+	return CLIP<int>(column * kRA2PSXLevel2AimColumns + row,
+			(int)kRA2PSXLevel2CoverFrames, (int)kRA2PSXLevel2FrameCount - 1);
+}
+
+void RA2PSXLevel2Scene::update(int aimX, int aimY) {
+	for (int layer = 0; layer < 2; ++layer) {
+		for (int axis = 0; axis < 2; ++axis) {
+			int &value = _scroll[layer][axis];
+			const int target = _scrollTarget[layer][axis];
+			const int step = _scrollStep[layer][axis];
+			if (value == target || !step)
+				continue;
+			value += step;
+			if ((step < 0 && value <= target) || (step > 0 && value >= target))
+				value = target;
+		}
+	}
+
+	if (_moving) {
+		if (--_delay >= 0)
+			return;
+		_delay = kRA2PSXLevel2RookieDelay;
+		// Leaning out walks 0 to 4, ducking back walks it down again.
+		if (!_out) {
+			if (_frame < kRA2PSXLevel2CoverFrames - 1) {
+				++_frame;
+			} else {
+				_moving = false;
+				_out = true;
+				_frame = aimFrame(aimX, aimY);
+			}
+		} else {
+			if (_frame >= kRA2PSXLevel2CoverFrames)
+				_frame = kRA2PSXLevel2CoverFrames - 1;
+			if (_frame > 0) {
+				--_frame;
+			} else {
+				_moving = false;
+				_out = false;
+			}
+		}
+		return;
+	}
+
+	if (_out)
+		_frame = aimFrame(aimX, aimY);
+}
+
+void RA2PSXLevel2Scene::drawLayer(Graphics::Surface &surface, const char *name,
+		int x, int y) const {
+	if (!_textures.has(name))
+		return;
+	_textures.draw(surface, name, x, y, Common::Rect(0, 0, 256, 256));
+}
+
+void RA2PSXLevel2Scene::drawFrame(Graphics::Surface &surface, const RA2PSXTexture &frame,
+		int x, int y) const {
+	for (int row = 0; row < frame.height; ++row) {
+		const int destY = y + row;
+		if (destY < 0 || destY >= surface.h)
+			continue;
+		for (int column = 0; column < frame.width; ++column) {
+			const int destX = x + column;
+			if (destX < 0 || destX >= surface.w)
+				continue;
+			const uint32 pixel = frame.pixels[row * frame.width + column];
+			if (!(pixel & 0x01000000))
+				continue;
+			surface.setPixel(destX, destY, surface.format.RGBToColor(
+					(pixel >> 16) & 0xff, (pixel >> 8) & 0xff, pixel & 0xff));
+		}
+	}
+}
+
+void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) const {
+	const RA2PSXLevel2PartInfo &part = info();
+	const int left = (surface.w - 320) / 2;
+	const int top = (surface.h - 240) / 2 + kRA2PSXLevel2SceneTop;
+	const int backX = _scroll[0][0] >> 16;
+	const int paraX = _scroll[1][0] >> 16;
+	surface.fillRect(Common::Rect(surface.w, surface.h), 0);
+
+	drawLayer(surface, "BACK_L", left - backX, top);
+	drawLayer(surface, "BACK_R", left + 256 - backX, top);
+	drawLayer(surface, "PARA_L", left - paraX, top);
+	if (part.hasMiddleStrip)
+		drawLayer(surface, "PARA_M", left + 0x36 - paraX, top);
+	drawLayer(surface, "PARA_R", left + 0x36 - paraX, top);
+
+	if ((uint)_frame < _rookie.size()) {
+		const RA2PSXLevel2Pose &pose = part.poses[_frame];
+		drawFrame(surface, _rookie[_frame], left + pose.x + part.rookieOffsetX,
+				(surface.h - 240) / 2 + pose.y + part.rookieOffsetY);
+	}
+
+	if (outOfCover() && _hud.has("CROSS"))
+		_hud.draw(surface, "CROSS", left + aimX - 4,
+				(surface.h - 240) / 2 + aimY - 3, Common::Rect(0, 0, 8, 7));
+}
+
+// The PSX release composites its waves from sprite sheets instead of playing a SMUSH
+// segment, but the level's shape is the same, so it rides runRebel2Level2 as well.
+class Rebel2PSX::Level2Handler : public Rebel2Level2Handler {
+public:
+	Level2Handler(Rebel2PSX &psx, const RA2PSXArchive &archive) :
+		_psx(psx), _archive(archive), _phaseState(0) {
+	}
+
+	bool shouldQuit() const override { return _psx._vm->shouldQuit(); }
+
+	bool playOpening() override {
+		return _psx.playVideo("S1/L02_INTR.STR", 1, false);
+	}
+
+	void beginAttempt() override {}
+
+	void beginPhase(int phase, bool) override {
+		_phaseState = 0;
+		if (!_scene.load(_archive, phase - 1))
+			warning("Rebel Assault II: could not load level 2 part %d", phase);
+	}
+
+	int16 waveBudget(int phase) override {
+		// The per difficulty budget table is not read out of the PSX build yet.
+		return (int16)(4 + phase);
+	}
+
+	bool playBackgroundWave(int phase) override { return showScene(phase); }
+	bool playWave(int phase, uint16) override { return showScene(phase); }
+
+	WaveCredit creditWave(int16, int16 *, int16) override {
+		// Nothing decodes r1L2P* or play* yet, so every phase reports itself finished
+		// rather than looping on waves that cannot be built.
+		WaveCredit credit;
+		credit.stop = true;
+		_phaseState = 0x0e;
+		return credit;
+	}
+
+	bool playPhaseEnd(int phase) override {
+		static const char *const cutscenes[] = {
+			"S1/L02_CUT1.STR", "S1/L02_CUT2.STR"
+		};
+		return _psx.playVideo(cutscenes[phase - 1], 1, false);
+	}
+
+	uint16 phaseState() const override { return _phaseState; }
+	bool playerDead() const override { return false; }
+	void accumulateKills() override {}
+	void accumulateMisses() override {}
+
+	bool handleDeath(int, Result &result) override {
+		result = kQuit;
+		return false;
+	}
+
+	void playComplete(int) override {
+		_psx.playVideo("S1/L02_CUT3.STR", 1, false);
+		_psx.playVideo("S1/L02_EXTR.STR", 1, false);
+	}
+
+private:
+	// Runs the part. The rookie leans out and ducks back, the crosshair picks one of the
+	// twenty five aim poses, and the walls slide between the two cover positions.
+	bool showScene(int phase) {
+		const RA2PSXLevel2PartInfo &part = kRA2PSXLevel2Parts[phase - 1];
+		const bool cursorWasVisible = CursorMan.isVisible();
+		CursorMan.showMouse(false);
+
+		int aimX = (part.aimLeft + part.aimRight) / 2;
+		int aimY = (part.aimTop + part.aimBottom) / 2;
+		bool left = false, right = false, up = false, down = false;
+		bool running = true;
+		const int viewX = ((int)_psx._vm->_screenWidth - 320) / 2;
+		const int viewY = ((int)_psx._vm->_screenHeight - 240) / 2;
+
+		while (running && !shouldQuit()) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				const bool pressed = event.type == Common::EVENT_KEYDOWN;
+				if (pressed || event.type == Common::EVENT_KEYUP) {
+					switch (event.kbd.keycode) {
+					case Common::KEYCODE_ESCAPE:
+						if (pressed)
+							running = false;
+						break;
+					case Common::KEYCODE_LEFT:
+					case Common::KEYCODE_a:
+						left = pressed;
+						break;
+					case Common::KEYCODE_RIGHT:
+					case Common::KEYCODE_d:
+						right = pressed;
+						break;
+					case Common::KEYCODE_UP:
+					case Common::KEYCODE_w:
+						up = pressed;
+						break;
+					case Common::KEYCODE_DOWN:
+					case Common::KEYCODE_s:
+						down = pressed;
+						break;
+					case Common::KEYCODE_SPACE:
+					case Common::KEYCODE_RETURN:
+						if (pressed && !event.kbdRepeat)
+							_scene.toggleCover();
+						break;
+					default:
+						break;
+					}
+				} else if (event.type == Common::EVENT_MOUSEMOVE) {
+					aimX = event.mouse.x - viewX;
+					aimY = event.mouse.y - viewY;
+				} else if (event.type == Common::EVENT_RBUTTONDOWN) {
+					_scene.toggleCover();
+				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+					if (!_scene.outOfCover())
+						_scene.toggleCover();
+				} else if (event.type == Common::EVENT_QUIT ||
+						event.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					_psx._vm->quitGame();
+				}
+			}
+
+			// Aiming only moves while the rookie is actually out of cover.
+			if (_scene.outOfCover()) {
+				aimX += (right ? 3 : 0) - (left ? 3 : 0);
+				aimY += (down ? 3 : 0) - (up ? 3 : 0);
+			}
+			aimX = CLIP<int>(aimX, part.aimLeft, part.aimRight);
+			aimY = CLIP<int>(aimY, part.aimTop, part.aimBottom);
+			_scene.update(aimX, aimY);
+
+			Graphics::Surface output;
+			output.create(_psx._vm->_screenWidth, _psx._vm->_screenHeight,
+					g_system->getScreenFormat());
+			_scene.draw(output, aimX, aimY);
+			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0,
+					output.w, output.h);
+			output.free();
+			g_system->updateScreen();
+			g_system->delayMillis(20);
+		}
+
+		CursorMan.showMouse(cursorWasVisible);
+		return !shouldQuit();
+	}
+
+	Rebel2PSX &_psx;
+	const RA2PSXArchive &_archive;
+	RA2PSXLevel2Scene _scene;
+	uint16 _phaseState;
+};
+
+Common::Error Rebel2PSX::runLevel2() {
+	Common::SeekableReadStream *stream = openResource(2);
+	if (!stream)
+		return Common::Error(Common::kReadingFailed,
+				_("Could not open the PlayStation Level 2 resources"));
+	RA2PSXArchive archive;
+	const bool loaded = archive.load(*stream);
+	delete stream;
+	if (!loaded)
+		return Common::Error(Common::kReadingFailed,
+				_("Could not read the PlayStation Level 2 resources"));
+
+	Level2Handler handler(*this, archive);
+	if (runRebel2Level2(handler, _vm->_rnd) == Rebel2Level2Handler::kError)
+		return Common::Error(Common::kReadingFailed,
+				_("Could not play the PlayStation Level 2 videos"));
+	return Common::kNoError;
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 037111c074b..95ff564b32a 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -362,9 +362,13 @@ Common::Error Rebel2PSX::runGame() {
 			const int picked = runChapterSelect(chapters);
 			if (!picked)
 				backedOut = true;
-			else if (picked > 1)
-				// Only chapter 1 is ported; stay on the list rather than
-				// pretending to start the rest.
+			else if (picked == 2) {
+				const Common::Error error = runLevel2();
+				if (error.getCode() != Common::kNoError)
+					return error;
+			} else if (picked > 2)
+				// Chapters 3 and up are not ported; stay on the list rather than
+				// pretending to start them.
 				warning("Rebel Assault II: chapter %d is not implemented yet", picked);
 			else
 				chapter = picked;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 405fc353427..48c6224f886 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -37,6 +37,7 @@ namespace Scumm {
 
 class ScummEngine_v7;
 class RA2PSXLevel1UI;
+class RA2PSXLevel2Scene;
 class RA2PSXMainMenuUI;
 class RA2PSXMovieText;
 class RA2PSXOptionsUI;
@@ -214,6 +215,46 @@ enum { kRA2PSXExplosionHeight = 56 };
 bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
 		Common::Array<RA2PSXTexture> &frames);
 
+// Level 2's three parts, each a sheet of backdrop halves, parallax walls and actors.
+enum {
+	kRA2PSXLevel2PartCount = 3,
+	kRA2PSXLevel2SceneTop = 28,
+	// Frames 0 to 4 are the rookie leaning out of cover; 5 to 29 are a five by five
+	// grid of aim poses picked from where the crosshair sits.
+	kRA2PSXLevel2CoverFrames = 5,
+	kRA2PSXLevel2FrameCount = 30,
+	kRA2PSXLevel2RookieDelay = 3,
+	kRA2PSXLevel2AimColumns = 5,
+	kRA2PSXLevel2AimRows = 5,
+	// The scroll eases to its target over this many ticks.
+	kRA2PSXLevel2ScrollSteps = 20
+};
+
+// Where each rookie frame is drawn, straight out of the original's per level table.
+struct RA2PSXLevel2Pose {
+	int16 x;
+	int16 y;
+	int16 width;
+	int16 height;
+};
+
+struct RA2PSXLevel2PartInfo {
+	const char *sheet;
+	const char *anims;
+	int backScroll;
+	int paraScroll;
+	int rookieOffsetX;
+	int rookieOffsetY;
+	bool hasMiddleStrip;
+	// Crosshair box, then the thresholds that split it into the aim grid.
+	int16 aimLeft, aimTop, aimRight, aimBottom;
+	int16 aimColumns[kRA2PSXLevel2AimColumns];
+	int16 aimRows[kRA2PSXLevel2AimRows];
+	RA2PSXLevel2Pose poses[kRA2PSXLevel2FrameCount];
+};
+
+extern const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount];
+
 // How far the level 1 background is panned and tilted inside its oversized frame, and the
 // matching shift the 3D scene rides along with.
 struct RA2PSXBackgroundView {
@@ -319,6 +360,7 @@ public:
 
 private:
 	class Level1Handler;
+	class Level2Handler;
 
 	enum MenuResult {
 		kMenuStart,
@@ -351,6 +393,7 @@ private:
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXModel &tieLaser,
 			Common::Array<RA2PSXModel> &debris, RA2PSXLevel1UI &ui);
+	Common::Error runLevel2();
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
 			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
 			const RA2PSXModel &tieLaser, const Common::Array<RA2PSXModel> &debris,
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 44d45d9796a..1d85fa398d2 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -52,6 +52,42 @@ private:
 	Common::Array<RA2PSXTexture> _textures;
 };
 
+// One part of level 2: the backdrop halves and parallax walls scroll behind the rookie,
+// who stands centred and ducks in and out of cover.
+class RA2PSXLevel2Scene {
+public:
+	RA2PSXLevel2Scene();
+
+	bool load(const RA2PSXArchive &archive, int part);
+	// Toggles between cover and the open; ignored while a move is already running.
+	void toggleCover();
+	void update(int aimX, int aimY);
+	bool outOfCover() const { return _out && !_moving; }
+	bool busy() const { return _moving; }
+	void draw(Graphics::Surface &surface, int aimX, int aimY) const;
+	const RA2PSXLevel2PartInfo &info() const;
+
+private:
+	void drawLayer(Graphics::Surface &surface, const char *name, int x, int y) const;
+	void drawFrame(Graphics::Surface &surface, const RA2PSXTexture &frame,
+			int x, int y) const;
+	void setScrollTarget(int backX, int backY, int paraX, int paraY);
+	int aimFrame(int aimX, int aimY) const;
+
+	RA2PSXTextureSet _textures;
+	RA2PSXTextureSet _hud;
+	Common::Array<RA2PSXTexture> _rookie;
+	int _part;
+	int _frame;
+	int _delay;
+	bool _out;
+	bool _moving;
+	// Background and parallax scroll, held in 16.16 with a target to ease toward.
+	int _scroll[2][2];
+	int _scrollTarget[2][2];
+	int _scrollStep[2][2];
+};
+
 // The GPU's semi-transparency mode 2: background minus source.
 void subtractRA2PSXRect(Graphics::Surface &surface, const Common::Rect &rect,
 		int r, int g, int b);
diff --git a/engines/scumm/insane/rebel2/rebel.cpp b/engines/scumm/insane/rebel2/rebel.cpp
index 882da9df8fc..03ccda4446e 100644
--- a/engines/scumm/insane/rebel2/rebel.cpp
+++ b/engines/scumm/insane/rebel2/rebel.cpp
@@ -231,6 +231,8 @@ InsaneRebel2::InsaneRebel2(ScummEngine_v7 *scumm) {
 	_rebelAutoPlay = false;
 	_rebelWaveState = 0;
 	_rebelPhaseState = 0;
+	_totalKills = 0;
+	_totalMisses = 0;
 
 	_rebelAutopilot = 0;
 	_rebelDamageLevel = 0;
diff --git a/engines/scumm/insane/rebel2/rebel.h b/engines/scumm/insane/rebel2/rebel.h
index ed180e99ffc..a9858d8f441 100644
--- a/engines/scumm/insane/rebel2/rebel.h
+++ b/engines/scumm/insane/rebel2/rebel.h
@@ -272,6 +272,7 @@ public:
 	// Per-level handlers.
 	class Level1Handler;
 	int runLevel1();
+	class Level2Handler;
 	int runLevel2();
 	int runLevel3();
 	int runLevel4();
@@ -685,6 +686,9 @@ public:
 
 	int _rebelWaveState;
 	int _rebelPhaseState;
+	// Kills and misses banked across the phases of a level 2 style attempt.
+	int _totalKills;
+	int _totalMisses;
 
 	int _rebelAutopilot;
 	int _rebelDamageLevel;
diff --git a/engines/scumm/insane/rebel2/runlevels.cpp b/engines/scumm/insane/rebel2/runlevels.cpp
index 66c0f2889bc..ca223022b9e 100644
--- a/engines/scumm/insane/rebel2/runlevels.cpp
+++ b/engines/scumm/insane/rebel2/runlevels.cpp
@@ -455,226 +455,245 @@ void InsaneRebel2::resetHandler7FlightState() {
 	}
 }
 
-int InsaneRebel2::runLevel2() {
-	int bonusCount = 0;
-	int totalKills = 0;
-	int totalMisses = 0;
-	int prevWaveState = 0;
-
-	playCinematic("LEV02/02CUT.SAN");
-	if (_vm->shouldQuit())
-		return kLevelQuit;
-
-	playLevelBegin(2);
-	if (_vm->shouldQuit())
-		return kLevelQuit;
+Rebel2Level2Handler::Result runRebel2Level2(Rebel2Level2Handler &handler,
+		Common::RandomSource &random) {
+	typedef Rebel2Level2Handler Handler;
 
-	clearBit(0);
+	if (!handler.playOpening())
+		return handler.shouldQuit() ? Handler::kQuit : Handler::kError;
 
-	while (!_vm->shouldQuit()) {
-		resetLevelAttemptState(1);
-		bonusCount = 0;
-		totalKills = 0;
-		totalMisses = 0;
-
-		resetLevelPhaseState(false);
+	while (!handler.shouldQuit()) {
+		handler.beginAttempt();
+		int bonusCount = 0;
+		uint16 previousWave = 0;
+		bool died = false;
+		Handler::Result deathResult = Handler::kQuit;
+
+		for (int phase = 1; phase <= 3 && !died; ++phase) {
+			handler.beginPhase(phase, phase > 1);
+			int16 budget = handler.waveBudget(phase);
+			if (!handler.playBackgroundWave(phase))
+				return Handler::kQuit;
+
+			if (phase == 1) {
+				// Phase 1 ignores what a wave credited and simply rolls one of three.
+				handler.creditWave(0x36, &budget, 0);
+				while (!handler.playerDead() && (handler.phaseState() & 0x06) != 0x06) {
+					if (handler.shouldQuit())
+						return Handler::kQuit;
+					if (!handler.playWave(1, (uint16)random.getRandomNumber(2)))
+						return Handler::kQuit;
+					handler.creditWave(0x36, &budget, 0x14);
+				}
+			} else if (phase == 2) {
+				// Phase 2 re-credits from a threshold of zero at the top of every pass.
+				while (true) {
+					const Handler::WaveCredit credit = handler.creditWave(0x3e, &budget, 0);
+					uint16 wave = credit.bits;
+					if (credit.stop || (handler.phaseState() & 0x0e) == 0x0e)
+						break;
+					if (handler.shouldQuit())
+						return Handler::kQuit;
+					if ((wave & 0x0c) == 0)
+						wave = (uint16)random.getRandomNumber(2) + 0x10;
+					if (!handler.playWave(2, wave))
+						return Handler::kQuit;
+				}
+			} else {
+				Handler::WaveCredit credit = handler.creditWave(0x3e, &budget, 0);
+				while (!credit.stop && (handler.phaseState() & 0x0e) != 0x0e) {
+					if (handler.shouldQuit())
+						return Handler::kQuit;
+					uint16 wave = credit.bits;
+					// One pass in eight sets the low bit, but never twice running.
+					if (!(previousWave & 1) && !random.getRandomNumber(7))
+						wave |= 1;
+					previousWave = wave;
+					if (!handler.playWave(3, wave))
+						return Handler::kQuit;
+					credit = handler.creditWave(0x3e, &budget, 0x14);
+				}
+			}
 
-		int16 budget = getWaveBudgetBase(1) + _vm->_rnd.getRandomNumber(2);
+			if (handler.phaseState() & 0x10)
+				++bonusCount;
+			// The last phase banks its kills before the death check, the others after.
+			if (phase == 3)
+				handler.accumulateKills();
 
-		debugC(DEBUG_INSANE, "Level 2 Phase 1 - playing 02P01_A.SAN (background) budget=%d", budget);
-		if (!playLevelSegment("LEV02/P1/02P01_A.SAN", 0x28))
-			return kLevelQuit;
+			if (handler.playerDead()) {
+				died = true;
+				if (!handler.handleDeath(phase, deathResult))
+					return deathResult;
+				break;
+			}
+			if (handler.shouldQuit())
+				return Handler::kQuit;
 
-		processWaveEnd(0x36, &budget, 0, 0);
+			if (phase == 3) {
+				handler.accumulateMisses();
+			} else {
+				if (!handler.playPhaseEnd(phase))
+					return Handler::kQuit;
+				handler.accumulateKills();
+				handler.accumulateMisses();
+			}
+		}
 
-		while (_playerDamage < 255 && (_rebelPhaseState & 0x06) != 0x06) {
-			if (_vm->shouldQuit())
-				return kLevelQuit;
+		if (died)
+			continue;
+		handler.playComplete(bonusCount);
+		return Handler::kComplete;
+	}
 
-			int variant = _vm->_rnd.getRandomNumber(2);  // 0-2
-			const char *variants[] = {
-				"LEV02/P1/02P01_B.SAN",
-				"LEV02/P1/02P01_C.SAN",
-				"LEV02/P1/02P01_D.SAN"
-			};
-			debugC(DEBUG_INSANE, "Phase 1 wave - playing %s (state=0x%x budget=%d)", variants[variant], _rebelPhaseState, budget);
-			if (!playLevelSegment(variants[variant], 0x428))
-				return kLevelQuit;
+	return Handler::kQuit;
+}
 
-			processWaveEnd(0x36, &budget, 0x14, 0);
-			debugC(DEBUG_INSANE, "Phase 1 wave done - state=0x%x (need 0x06) budget=%d", _rebelPhaseState, budget);
-		}
+// The DOS release plays each wave as a SMUSH segment; everything else about the level's
+// shape lives in runRebel2Level2.
+class InsaneRebel2::Level2Handler : public Rebel2Level2Handler {
+public:
+	explicit Level2Handler(InsaneRebel2 &rebel) : _rebel(rebel) {}
 
-		if ((_rebelPhaseState & 0x10) != 0)
-			bonusCount++;
+	bool shouldQuit() const override { return _rebel._vm->shouldQuit(); }
 
-		if (_playerDamage >= 255) {
-			int levelResult;
-			if (handleLevelDeath(2, _currentPhase, "LEV02/02DIE.SAN", "LEV02/02RETRY.SAN", levelResult))
-				continue;
-			return levelResult;
-		}
-		if (_vm->shouldQuit())
-			return kLevelQuit;
+	bool playOpening() override {
+		_rebel.playCinematic("LEV02/02CUT.SAN");
+		if (shouldQuit())
+			return false;
+		_rebel.playLevelBegin(2);
+		if (shouldQuit())
+			return false;
+		_rebel.clearBit(0);
+		return true;
+	}
 
-		_rebelHandler = 0;
-		_rebelStatusBarSprite = 0;
-		if (!playLevelSegment("LEV02/02PST1.SAN", 0x28, false))
-			return kLevelQuit;
+	void beginAttempt() override {
+		_rebel.resetLevelAttemptState(1);
+		_rebel._totalKills = 0;
+		_rebel._totalMisses = 0;
+	}
 
-		totalKills += _rebelKillCounter;
-		totalMisses += _rebelHitCounter;
+	void beginPhase(int phase, bool clearEnemies) override {
+		_rebel._currentPhase = phase;
+		_rebel.resetLevelPhaseState(clearEnemies);
+	}
 
-		_currentPhase = 2;
-		resetLevelPhaseState(true);
+	int16 waveBudget(int phase) override {
+		return _rebel.getWaveBudgetBase(phase) + _rebel._vm->_rnd.getRandomNumber(2);
+	}
 
-		budget = getWaveBudgetBase(2) + _vm->_rnd.getRandomNumber(2);
+	bool playBackgroundWave(int phase) override {
+		static const char *const backgrounds[] = {
+			"LEV02/P1/02P01_A.SAN", "LEV02/P2/02P02_A.SAN", "LEV02/P3/02P03_A.SAN"
+		};
+		if (phase > 1)
+			_rebel._rebelHandler = 8;
+		return _rebel.playLevelSegment(backgrounds[phase - 1], 0x28);
+	}
 
-		_rebelHandler = 8;
+	bool playWave(int phase, uint16 selection) override {
+		return _rebel.playLevelSegment(waveFile(phase, selection), 0x428);
+	}
 
-		debugC(DEBUG_INSANE, "Level 2 Phase 2 - playing 02P02_A.SAN (background) budget=%d", budget);
-		if (!playLevelSegment("LEV02/P2/02P02_A.SAN", 0x28))
-			return kLevelQuit;
+	WaveCredit creditWave(int16 mask, int16 *budget, int16 threshold) override {
+		const WaveEndResult result = _rebel.processWaveEnd(mask, budget, threshold, 0);
+		WaveCredit credit;
+		credit.bits = result.creditedBits;
+		credit.stop = result.shouldStop();
+		return credit;
+	}
 
-		while (true) {
-			WaveEndResult waveEnd = processWaveEnd(0x3e, &budget, 0, 0);
-			uint16 waveSelect = waveEnd.creditedBits;
-			if (waveEnd.shouldStop() || (_rebelPhaseState & 0x0e) == 0x0e)
-				break;
-			if (_vm->shouldQuit())
-				return kLevelQuit;
+	bool playPhaseEnd(int phase) override {
+		_rebel._rebelHandler = 0;
+		_rebel._rebelStatusBarSprite = 0;
+		return _rebel.playLevelSegment(phase == 1 ? "LEV02/02PST1.SAN" : "LEV02/02PST2.SAN",
+				0x28, false);
+	}
 
+	uint16 phaseState() const override { return (uint16)_rebel._rebelPhaseState; }
+	bool playerDead() const override { return _rebel._playerDamage >= 255; }
+	void accumulateKills() override { _rebel._totalKills += _rebel._rebelKillCounter; }
+	void accumulateMisses() override { _rebel._totalMisses += _rebel._rebelHitCounter; }
+
+	bool handleDeath(int phase, Result &result) override {
+		int levelResult;
+		if (_rebel.handleLevelDeath(2, phase, "LEV02/02DIE.SAN", "LEV02/02RETRY.SAN",
+				levelResult))
+			return true;
+		result = levelResult == kLevelGameOver ? kGameOver :
+				levelResult == kLevelQuit ? kQuit : kError;
+		return false;
+	}
 
-			if ((waveSelect & 0x0c) == 0) {
-				waveSelect = _vm->_rnd.getRandomNumber(2) + 0x10;
-			}
+	void playComplete(int bonusCount) override {
+		const int accuracy = _rebel.calculateAccuracy(_rebel._totalKills, _rebel._totalMisses);
+		_rebel.playLevelEnd(2, accuracy, -1, bonusCount > 2);
+		_rebel._levelUnlocked[2] = true;
+	}
 
-			const char *filename;
-			switch (waveSelect) {
+private:
+	static const char *waveFile(int phase, uint16 selection) {
+		if (phase == 1) {
+			static const char *const variants[] = {
+				"LEV02/P1/02P01_B.SAN", "LEV02/P1/02P01_C.SAN", "LEV02/P1/02P01_D.SAN"
+			};
+			return variants[selection % 3];
+		}
+		if (phase == 2) {
+			switch (selection) {
 			case 4: case 6:
-				filename = "LEV02/P2/02P02_B.SAN"; break;
+				return "LEV02/P2/02P02_B.SAN";
 			case 8: case 10:
-				filename = "LEV02/P2/02P02_C.SAN"; break;
+				return "LEV02/P2/02P02_C.SAN";
 			case 0x0c: case 0x0e:
-				filename = "LEV02/P2/02P02_A.SAN"; break;
+				return "LEV02/P2/02P02_A.SAN";
 			case 0x11:
-				filename = "LEV02/P2/02P02_E.SAN"; break;
+				return "LEV02/P2/02P02_E.SAN";
 			case 0x12:
-				filename = "LEV02/P2/02P02_F.SAN"; break;
+				return "LEV02/P2/02P02_F.SAN";
 			default:
-				filename = "LEV02/P2/02P02_D.SAN"; break;
+				return "LEV02/P2/02P02_D.SAN";
 			}
-
-			debugC(DEBUG_INSANE, "Phase 2 wave - playing %s (state=0x%x sel=0x%x budget=%d)", filename, _rebelPhaseState, waveSelect, budget);
-			if (!playLevelSegment(filename, 0x428))
-				return kLevelQuit;
 		}
-
-		if ((_rebelPhaseState & 0x10) != 0)
-			bonusCount++;
-
-		if (_playerDamage >= 255) {
-			int levelResult;
-			if (handleLevelDeath(2, _currentPhase, "LEV02/02DIE.SAN", "LEV02/02RETRY.SAN", levelResult))
-				continue;
-			return levelResult;
+		switch (selection) {
+		case 0:
+			return "LEV02/P3/02P03_H.SAN";
+		case 2:
+			return "LEV02/P3/02P03_G.SAN";
+		case 4:
+			return "LEV02/P3/02P03_F.SAN";
+		case 6:
+			return "LEV02/P3/02P03_E.SAN";
+		case 8:
+			return "LEV02/P3/02P03_D.SAN";
+		case 10:
+			return "LEV02/P3/02P03_C.SAN";
+		case 0x0c:
+			return "LEV02/P3/02P03_B.SAN";
+		case 0x0e:
+			return "LEV02/P3/02P03_A.SAN";
+		default:
+			return "LEV02/P3/02P03_I.SAN";
 		}
-		if (_vm->shouldQuit())
-			return kLevelQuit;
-
-		_rebelHandler = 0;
-		_rebelStatusBarSprite = 0;
-		if (!playLevelSegment("LEV02/02PST2.SAN", 0x28, false))
-			return kLevelQuit;
-
-		totalKills += _rebelKillCounter;
-		totalMisses += _rebelHitCounter;
-
-		_currentPhase = 3;
-		resetLevelPhaseState(true);
-		prevWaveState = 0;
-
-		budget = getWaveBudgetBase(3) + _vm->_rnd.getRandomNumber(2);
-
-		_rebelHandler = 8;
-
-		debugC(DEBUG_INSANE, "Level 2 Phase 3 - playing 02P03_A.SAN (background) budget=%d", budget);
-		if (!playLevelSegment("LEV02/P3/02P03_A.SAN", 0x28))
-			return kLevelQuit;
-
-		{
-			WaveEndResult waveEnd = processWaveEnd(0x3e, &budget, 0, 0);
-
-			while (!waveEnd.shouldStop() && (_rebelPhaseState & 0x0e) != 0x0e) {
-				if (_vm->shouldQuit())
-					return kLevelQuit;
-
-				uint16 waveSelect = waveEnd.creditedBits;
-
-				if (((prevWaveState & 1) == 0) && (_vm->_rnd.getRandomNumber(7) == 0)) {
-					waveSelect |= 1;
-				}
-				prevWaveState = waveSelect;
-
-				const char *filename;
-				switch (waveSelect) {
-				case 0:
-					filename = "LEV02/P3/02P03_H.SAN"; break;
-				case 2:
-					filename = "LEV02/P3/02P03_G.SAN"; break;
-				case 4:
-					filename = "LEV02/P3/02P03_F.SAN"; break;
-				case 6:
-					filename = "LEV02/P3/02P03_E.SAN"; break;
-				case 8:
-					filename = "LEV02/P3/02P03_D.SAN"; break;
-				case 10:
-					filename = "LEV02/P3/02P03_C.SAN"; break;
-				case 0x0c:
-					filename = "LEV02/P3/02P03_B.SAN"; break;
-				case 0x0e:
-					filename = "LEV02/P3/02P03_A.SAN"; break;
-				default:
-					filename = "LEV02/P3/02P03_I.SAN"; break;
-				}
-
-				debugC(DEBUG_INSANE, "Phase 3 wave - playing %s (state=0x%x sel=0x%x budget=%d)", filename, _rebelPhaseState, waveSelect, budget);
-				if (!playLevelSegment(filename, 0x428))
-					return kLevelQuit;
-
-				waveEnd = processWaveEnd(0x3e, &budget, 0x14, 0);
-				debugC(DEBUG_INSANE, "Phase 3 wave done - state=0x%x (need 0x0e) budget=%d", _rebelPhaseState, budget);
-			}
-		}
-
-		if ((_rebelPhaseState & 0x10) != 0)
-			bonusCount++;
-		totalKills += _rebelKillCounter;
-
-		if (_playerDamage >= 255) {
-			int levelResult;
-			if (handleLevelDeath(2, _currentPhase, "LEV02/02DIE.SAN", "LEV02/02RETRY.SAN", levelResult))
-				continue;
-			return levelResult;
-		}
-		if (_vm->shouldQuit())
-			return kLevelQuit;
+	}
 
-		{
-			totalMisses += _rebelHitCounter;
-			int accuracy = calculateAccuracy(totalKills, totalMisses);
-			debugC(DEBUG_INSANE, "Level 2 completed! kills=%d misses=%d accuracy=%d%% bonus=%d",
-				totalKills, totalMisses, accuracy, bonusCount);
-			playLevelEnd(2, accuracy, -1, bonusCount > 2);
-		}
+	InsaneRebel2 &_rebel;
+};
 
-		_levelUnlocked[2] = true;
+int InsaneRebel2::runLevel2() {
+	Level2Handler handler(*this);
+	switch (runRebel2Level2(handler, _vm->_rnd)) {
+	case Rebel2Level2Handler::kComplete:
 		return kLevelNextLevel;
+	case Rebel2Level2Handler::kGameOver:
+		return kLevelGameOver;
+	default:
+		return kLevelQuit;
 	}
-
-	return kLevelQuit;
 }
 
+
 int InsaneRebel2::runLevel3() {
 	int phase1Score = 0;
 	int phase1FlightErrors = 0;
diff --git a/engines/scumm/insane/rebel2/shared.h b/engines/scumm/insane/rebel2/shared.h
index f143d3488f1..2c2aeb6a4e3 100644
--- a/engines/scumm/insane/rebel2/shared.h
+++ b/engines/scumm/insane/rebel2/shared.h
@@ -15,6 +15,10 @@
 
 #include "common/keyboard.h"
 
+namespace Common {
+class RandomSource;
+}
+
 namespace Scumm {
 
 enum Rebel2MenuCommand {
@@ -51,6 +55,52 @@ public:
 
 Rebel2Level1Handler::Result runRebel2Level1(Rebel2Level1Handler &handler, int lives);
 
+// Level 2 - and the chapters built on the same cover shooter - runs three phases of waves
+// against a budget. The flow below is the level's design rather than any one release's, so
+// the handler only has to play what the runner asks for and report the state it keeps.
+class Rebel2Level2Handler {
+public:
+	enum Result {
+		kQuit,
+		kComplete,
+		kGameOver,
+		kError
+	};
+
+	// What a finished wave credited toward picking the next one, and whether the phase
+	// should stop regardless.
+	struct WaveCredit {
+		WaveCredit() : bits(0), stop(false) {}
+
+		uint16 bits;
+		bool stop;
+	};
+
+	virtual ~Rebel2Level2Handler() {}
+	virtual bool shouldQuit() const = 0;
+
+	virtual bool playOpening() = 0;
+	virtual void beginAttempt() = 0;
+	virtual void beginPhase(int phase, bool clearEnemies) = 0;
+	virtual int16 waveBudget(int phase) = 0;
+	virtual bool playBackgroundWave(int phase) = 0;
+	virtual bool playWave(int phase, uint16 selection) = 0;
+	virtual WaveCredit creditWave(int16 mask, int16 *budget, int16 threshold) = 0;
+	virtual bool playPhaseEnd(int phase) = 0;
+
+	virtual uint16 phaseState() const = 0;
+	virtual bool playerDead() const = 0;
+	virtual void accumulateKills() = 0;
+	virtual void accumulateMisses() = 0;
+
+	// Returns true when the player still has a life and the attempt should restart.
+	virtual bool handleDeath(int phase, Result &result) = 0;
+	virtual void playComplete(int bonusCount) = 0;
+};
+
+Rebel2Level2Handler::Result runRebel2Level2(Rebel2Level2Handler &handler,
+		Common::RandomSource &random);
+
 inline Rebel2MenuCommand getRebel2MenuCommand(const Common::KeyState &key) {
 	switch (key.keycode) {
 	case Common::KEYCODE_UP:
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 93edc2ba909..d2e5f1dc058 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -181,6 +181,7 @@ ifdef ENABLE_REBEL2_PSX
 MODULE_OBJS += \
 	insane/rebel2/psx/audio.o \
 	insane/rebel2/psx/level1.o \
+	insane/rebel2/psx/level2.o \
 	insane/rebel2/psx/menu.o \
 	insane/rebel2/psx/model.o \
 	insane/rebel2/psx/psx.o \


Commit: 61864ceb266678865531f649b6df16c0bb4a573f
    https://github.com/scummvm/scummvm/commit/61864ceb266678865531f649b6df16c0bb4a573f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: correctly implemented sliding effect and enemy waves in L2 in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level2.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
index 24fb957cc25..23e2a7010ec 100644
--- a/engines/scumm/insane/rebel2/psx/level2.cpp
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -35,8 +35,18 @@ namespace Scumm {
 
 // Level 2 is a cover shooter, and the original runs its three parts as separate levels:
 // 2, 0xc9 and 0xca. The same engine drives chapters 11 and 12.
+//
+// Every trooper slot owns four animations - stepping out, an aimed burst, a wild burst
+// and falling - at play script indices 8i to 8i+3, with its bolt at 4+8i.
 const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
-	{ "l2P1bg", "r1L2P1", 0x26, 0x68, 0, 0x26, true,
+	{ "l2P1bg", "r1L2P1",
+		{ { 0x26, 0 }, { 0x68, 0 } }, { { 0, 0 }, { 0, 0 } }, 0,
+		{ { "BACK_L",   0, 28, 256, 190, 0 },
+		  { "BACK_R", 256, 28,  64, 190, 0 },
+		  { "PARA_L",   0, 28,  54, 190, 1 },
+		  { "PARA_M",  54, 28, 180,  25, 1 },
+		  { "PARA_R", 234, 28, 190, 190, 1 } },
+		0, 0x26,
 		0x2a, 0x32, 0xcf, 0x8d,
 		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
 		{
@@ -50,8 +60,19 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
 		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
 		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
-		} },
-	{ "l2P2bg", "r1L2P2", 0x00, 0x00, -10, 0x12, false,
+		},
+		2, { "TRP_0", "TRP_1", nullptr }, { "LAS_0", "LAS_1", nullptr },
+		{ { { 7, 0x1f }, { 7, 0x1a }, { 8, 0x0b } },
+		  { { 7, 0x36 }, { 7, 0x28 }, { 8, 0x1a } },
+		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } },
+	{ "l2P2bg", "r1L2P2",
+		{ { 0, 0x28 }, { 0, 0x28 } }, { { 0, 0x0a }, { 0, -20 } }, 1,
+		{ { "BACK_L",   0,  28, 256, 200, 0 },
+		  { "BACK_R", 256,  28,  64, 200, 0 },
+		  { "PARA_L",   0,  28,  64, 230, 1 },
+		  { "PARA_R",  64, 151, 256, 107, 1 },
+		  { nullptr,    0,   0,   0,   0, 0 } },
+		-10, 0x12,
 		0x1c, 0x26, 0x11e, 0xa0,
 		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
 		{
@@ -65,8 +86,19 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
 		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
 		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
-		} },
-	{ "l2P3bg", "r1L2P3", 0x00, 0x00, 0, 0x23, false,
+		},
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
+		{ { { 0x12, 0x2b }, { 0x0e, 0x24 }, { 0x0e, 0x19 } },
+		  { { 0x17, 0x3a }, { 0x15, 0x28 }, { 0x15, 0x1e } },
+		  { { 0x0d, 0x3b }, { 0x0d, 0x29 }, { 0x0f, 0x1c } } } },
+	{ "l2P3bg", "r1L2P3",
+		{ { 0x0f, 0 }, { -114, 0 } }, { { 0x1e, 0 }, { -14, 0 } }, 0,
+		{ { "BACK_L",   0, 28, 256, 190, 0 },
+		  { "BACK_R", 256, 28,  94, 190, 0 },
+		  { "PARA_L",   0, 28,  46, 190, 1 },
+		  { "PARA_R",  46, 28,  44, 190, 1 },
+		  { nullptr,    0,  0,   0,   0, 0 } },
+		0, 0x23,
 		0x1a, 0x43, 0xc9, 0x92,
 		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
 		{
@@ -80,14 +112,48 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
 		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
 		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
-		} }
+		},
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
+		{ { { 0, 0 }, { 0, 0 }, { 0, 0 } },
+		  { { 0, 0 }, { 0, 0 }, { 0, 0 } },
+		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } }
 };
 
-RA2PSXLevel2Scene::RA2PSXLevel2Scene() : _part(0), _frame(0), _delay(0), _out(false),
-		_moving(false) {
+// How many troopers a part sends, as base plus a roll of range.
+const int16 kRA2PSXLevel2WaveTable[kRA2PSXLevel2PartCount][3][2] = {
+	{ { 4, 0 }, { 6, 2 }, { 6, 4 } },
+	{ { 5, 0 }, { 6, 3 }, { 7, 4 } },
+	{ { 6, 0 }, { 8, 4 }, { 9, 5 } }
+};
+
+// { reappear base, reappear range, aim chance percent, respawn base, respawn range }
+const int16 kRA2PSXLevel2SlotTable[kRA2PSXLevel2PartCount][3][5] = {
+	{ { 140, 20, 66, 120, 60 }, { 100, 20, 66, 120, 60 }, { 80, 20, 50, 90, 40 } },
+	{ { 140, 60, 66, 120, 60 }, { 100, 60, 58, 120, 60 }, { 80, 20, 50, 90, 40 } },
+	{ { 140, 80, 66, 120, 60 }, { 100, 80, 50, 120, 60 }, { 80, 20, 50, 90, 40 } }
+};
+
+const int16 kRA2PSXLevel2FireTable[2][3][2] = {
+	{ { 9, 6 }, { 8, 6 }, { 6, 4 } },
+	{ { 9, 6 }, { 10, 6 }, { 6, 4 } }
+};
+
+const int16 kRA2PSXLevel2BoltTable[kRA2PSXLevel2PartCount][3][2] = {
+	{ { 30, 180 }, { 40, 256 }, { 50, 256 } },
+	{ { 30, 180 }, { 40, 256 }, { 50, 256 } },
+	{ { 30, 180 }, { 40, 256 }, { 50, 256 } }
+};
+
+const int16 kRA2PSXLevel2KillScore[3] = { 80, 100, 150 };
+
+RA2PSXLevel2Scene::RA2PSXLevel2Scene() : _part(0), _difficulty(0), _frame(0), _delay(0),
+		_out(false), _moving(false), _tick(0), _remaining(0), _active(0), _clearTicks(0),
+		_kills(0), _misses(0) {
 	for (int layer = 0; layer < 2; ++layer) {
-		for (int axis = 0; axis < 2; ++axis)
-			_scroll[layer][axis] = _scrollTarget[layer][axis] = _scrollStep[layer][axis] = 0;
+		for (int axis = 0; axis < 2; ++axis) {
+			_scroll[layer][axis] = _scrollTarget[layer][axis] = 0;
+			_scrollStep[layer][axis] = _scrollHold[layer][axis] = 0;
+		}
 	}
 }
 
@@ -95,7 +161,8 @@ const RA2PSXLevel2PartInfo &RA2PSXLevel2Scene::info() const {
 	return kRA2PSXLevel2Parts[_part];
 }
 
-bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part) {
+bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part, int difficulty,
+		Common::RandomSource &random) {
 	if (part < 0 || part >= kRA2PSXLevel2PartCount)
 		return false;
 
@@ -130,32 +197,197 @@ bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part) {
 	if (archive.getMember("trpTex", hud))
 		_hud.append(hud);
 
+	Common::Array<byte> script;
+	_play.clear();
+	if (!archive.getMember(Common::String::format("play%d", part + 1), script) ||
+			!loadRA2PSXPlayScript(script, _play))
+		return false;
+
 	_part = part;
+	_difficulty = CLIP(difficulty, 0, 2);
 	_frame = 0;
 	_delay = 0;
 	_out = false;
 	_moving = false;
 	const RA2PSXLevel2PartInfo &part_ = kRA2PSXLevel2Parts[part];
-	_scroll[0][0] = _scrollTarget[0][0] = part_.backScroll << 16;
-	_scroll[1][0] = _scrollTarget[1][0] = part_.paraScroll << 16;
-	_scroll[0][1] = _scrollTarget[0][1] = 0;
-	_scroll[1][1] = _scrollTarget[1][1] = 0;
-	for (int layer = 0; layer < 2; ++layer) {
-		for (int axis = 0; axis < 2; ++axis)
-			_scrollStep[layer][axis] = 0;
+	for (int plane = 0; plane < 2; ++plane) {
+		for (int axis = 0; axis < 2; ++axis) {
+			_scroll[plane][axis] = _scrollTarget[plane][axis] =
+					part_.coverScroll[plane][axis] << 16;
+			_scrollStep[plane][axis] = _scrollHold[plane][axis] = 0;
+		}
+	}
+
+	const int16 *wave = kRA2PSXLevel2WaveTable[part][_difficulty];
+	_tick = 0;
+	_remaining = wave[0] + (wave[1] ? (int)random.getRandomNumber(wave[1]) : 0);
+	_active = 0;
+	_clearTicks = 0;
+	_kills = 0;
+	_misses = 0;
+	for (int i = 0; i < kRA2PSXLevel2TrooperCount; ++i) {
+		_troopers[i] = RA2PSXLevel2Actor();
+		_bolts[i] = RA2PSXLevel2Actor();
+		// Nothing steps out before the part has settled.
+		_troopers[i].slotTick = slotDelay(kRA2PSXLevel2SlotTable[part][_difficulty][0],
+				kRA2PSXLevel2SlotTable[part][_difficulty][1], random);
 	}
 	return true;
 }
 
-void RA2PSXLevel2Scene::setScrollTarget(int backX, int backY, int paraX, int paraY) {
-	_scrollTarget[0][0] = backX;
-	_scrollTarget[0][1] = backY;
-	_scrollTarget[1][0] = paraX;
-	_scrollTarget[1][1] = paraY;
-	for (int layer = 0; layer < 2; ++layer) {
-		for (int axis = 0; axis < 2; ++axis)
-			_scrollStep[layer][axis] =
-					(_scrollTarget[layer][axis] - _scroll[layer][axis]) / kRA2PSXLevel2ScrollSteps;
+int RA2PSXLevel2Scene::slotDelay(int base, int range, Common::RandomSource &random) const {
+	return _tick + base + (range ? (int)random.getRandomNumber(range) : 0);
+}
+
+void RA2PSXLevel2Scene::startActor(RA2PSXLevel2Actor &actor, int state, int animation) {
+	actor.state = state;
+	actor.animation = animation;
+	actor.frame = 0;
+	actor.hold = kRA2PSXLevel2FrameTicks;
+	actor.hit = false;
+	actor.fireNext = 0;
+	actor.fireEnd = 0;
+}
+
+const RA2PSXPlayFrame *RA2PSXLevel2Scene::actorFrame(const RA2PSXLevel2Actor &actor) const {
+	if (actor.animation < 0 || (uint)actor.animation >= _play.size())
+		return nullptr;
+	const RA2PSXPlayAnimation &animation = _play[actor.animation];
+	if ((uint)actor.frame >= animation.size())
+		return nullptr;
+	return &animation[actor.frame];
+}
+
+void RA2PSXLevel2Scene::advanceActor(RA2PSXLevel2Actor &actor) {
+	const RA2PSXPlayFrame *frame = actorFrame(actor);
+	if (!frame || (frame->flags & kRA2PSXPlayLastFrame))
+		return;
+	++actor.frame;
+}
+
+int RA2PSXLevel2Scene::updateEnemies(Common::RandomSource &random) {
+	++_tick;
+	const RA2PSXLevel2PartInfo &part = info();
+	const int16 *slot = kRA2PSXLevel2SlotTable[_part][_difficulty];
+	const int16 *bolt = kRA2PSXLevel2BoltTable[_part][_difficulty];
+	int damage = 0;
+
+	for (int i = 0; i < part.trooperCount; ++i) {
+		RA2PSXLevel2Actor &trooper = _troopers[i];
+		RA2PSXLevel2Actor &shot = _bolts[i];
+
+		// The bolt goes first: its flagged frame is the moment the shot lands.
+		if (shot.state != kRA2PSXLevel2StateIdle && --shot.hold <= 0) {
+			shot.hold = kRA2PSXLevel2FrameTicks;
+			const RA2PSXPlayFrame *frame = actorFrame(shot);
+			if (frame && (frame->flags & kRA2PSXPlayHitsPlayer) && exposed() &&
+					(int)random.getRandomNumber(99) < bolt[0])
+				damage += bolt[1];
+			if (!frame || (frame->flags & kRA2PSXPlayLastFrame))
+				shot.state = kRA2PSXLevel2StateIdle;
+			else
+				++shot.frame;
+		}
+
+		switch (trooper.state) {
+		case kRA2PSXLevel2StateIdle:
+			// A fresh trooper only steps out while the wave still owes bodies.
+			if (_tick >= trooper.slotTick && _remaining - _active > 0) {
+				startActor(trooper, kRA2PSXLevel2StateAppear, i * 8);
+				++_active;
+				trooper.fireNext = part.fireWindows[i][0].start;
+				trooper.fireEnd = part.fireWindows[i][0].end;
+				if (!trooper.fireEnd)
+					startActor(shot, kRA2PSXLevel2StateShot, 4 + i * 8);
+			}
+			continue;
+		case kRA2PSXLevel2StateCover:
+			if (_tick >= trooper.slotTick) {
+				const bool aimed = (int)random.getRandomNumber(99) < slot[2];
+				const int state = aimed ? kRA2PSXLevel2StateAimed : kRA2PSXLevel2StateWild;
+				startActor(trooper, state, i * 8 + (aimed ? 1 : 2));
+				trooper.fireNext = part.fireWindows[i][aimed ? 1 : 2].start;
+				trooper.fireEnd = part.fireWindows[i][aimed ? 1 : 2].end;
+				if (!trooper.fireEnd)
+					startActor(shot, kRA2PSXLevel2StateShot, i * 8 + (aimed ? 5 : 6));
+			}
+			continue;
+		default:
+			break;
+		}
+
+		// Parts one and two shoot on a frame window inside the animation instead.
+		if (trooper.fireEnd && trooper.frame >= trooper.fireNext &&
+				trooper.frame <= trooper.fireEnd) {
+			const int16 *cadence = kRA2PSXLevel2FireTable[MIN(_part, 1)][_difficulty];
+			startActor(shot, kRA2PSXLevel2StateShot, 4 + i * 8);
+			trooper.fireNext = trooper.frame + cadence[0] +
+					(int)random.getRandomNumber(cadence[1]);
+		}
+
+		if (--trooper.hold > 0)
+			continue;
+		trooper.hold = kRA2PSXLevel2FrameTicks;
+		const RA2PSXPlayFrame *frame = actorFrame(trooper);
+		if (frame && !(frame->flags & kRA2PSXPlayLastFrame)) {
+			++trooper.frame;
+			continue;
+		}
+
+		if (trooper.state == kRA2PSXLevel2StateDie) {
+			trooper.state = kRA2PSXLevel2StateIdle;
+			--_active;
+			--_remaining;
+			trooper.slotTick = slotDelay(slot[3], slot[4], random);
+			continue;
+		}
+		// It ducked back untouched, which the original scores as a miss.
+		++_misses;
+		trooper.state = kRA2PSXLevel2StateCover;
+		trooper.slotTick = slotDelay(slot[0], slot[1], random);
+	}
+
+	if (_remaining <= 0)
+		++_clearTicks;
+	return damage;
+}
+
+int RA2PSXLevel2Scene::shoot(int aimX, int aimY) {
+	if (!outOfCover())
+		return 0;
+
+	for (int i = 0; i < info().trooperCount; ++i) {
+		RA2PSXLevel2Actor &trooper = _troopers[i];
+		if (trooper.state == kRA2PSXLevel2StateIdle ||
+				trooper.state == kRA2PSXLevel2StateCover ||
+				trooper.state == kRA2PSXLevel2StateDie)
+			continue;
+		const RA2PSXPlayFrame *frame = actorFrame(trooper);
+		if (!frame || !(frame->flags & kRA2PSXPlayTargetable))
+			continue;
+		if (aimX < frame->boxLeft || aimX > frame->boxRight ||
+				aimY < frame->boxTop || aimY > frame->boxBottom)
+			continue;
+
+		startActor(trooper, kRA2PSXLevel2StateDie, i * 8 + 3);
+		_bolts[i].state = kRA2PSXLevel2StateIdle;
+		++_kills;
+		return kRA2PSXLevel2KillScore[_difficulty];
+	}
+	return 0;
+}
+
+void RA2PSXLevel2Scene::setScrollTarget(const int16 target[2][2]) {
+	const RA2PSXLevel2PartInfo &part = info();
+	for (int plane = 0; plane < 2; ++plane) {
+		for (int axis = 0; axis < 2; ++axis) {
+			_scrollTarget[plane][axis] = target[plane][axis] << 16;
+			_scrollStep[plane][axis] =
+					(_scrollTarget[plane][axis] - _scroll[plane][axis]) / kRA2PSXLevel2ScrollSteps;
+			// Only the axis the part actually slides along eases out.
+			_scrollHold[plane][axis] =
+					axis == part.scrollAxis ? kRA2PSXLevel2ScrollHold : 1000;
+		}
 	}
 }
 
@@ -163,12 +395,7 @@ void RA2PSXLevel2Scene::toggleCover() {
 	if (_moving)
 		return;
 	_moving = true;
-	if (_out) {
-		// Ducking back puts the walls where they started.
-		setScrollTarget(info().backScroll << 16, 0, info().paraScroll << 16, 0);
-	} else {
-		setScrollTarget(0, 0, 0, 0);
-	}
+	setScrollTarget(_out ? info().coverScroll : info().openScroll);
 }
 
 int RA2PSXLevel2Scene::aimFrame(int aimX, int aimY) const {
@@ -187,10 +414,14 @@ void RA2PSXLevel2Scene::update(int aimX, int aimY) {
 	for (int layer = 0; layer < 2; ++layer) {
 		for (int axis = 0; axis < 2; ++axis) {
 			int &value = _scroll[layer][axis];
+			int &step = _scrollStep[layer][axis];
 			const int target = _scrollTarget[layer][axis];
-			const int step = _scrollStep[layer][axis];
 			if (value == target || !step)
 				continue;
+			if (_scrollHold[layer][axis] > 0)
+				--_scrollHold[layer][axis];
+			else
+				step = (step >> 8) * (kRA2PSXLevel2ScrollDamping >> 8);
 			value += step;
 			if ((step < 0 && value <= target) || (step > 0 && value >= target))
 				value = target;
@@ -227,11 +458,14 @@ void RA2PSXLevel2Scene::update(int aimX, int aimY) {
 		_frame = aimFrame(aimX, aimY);
 }
 
-void RA2PSXLevel2Scene::drawLayer(Graphics::Surface &surface, const char *name,
-		int x, int y) const {
-	if (!_textures.has(name))
+void RA2PSXLevel2Scene::drawLayer(Graphics::Surface &surface,
+		const RA2PSXLevel2Layer &layer, int left, int top) const {
+	if (!layer.name || !_textures.has(layer.name))
 		return;
-	_textures.draw(surface, name, x, y, Common::Rect(0, 0, 256, 256));
+	_textures.draw(surface, layer.name,
+			left + layer.x - (_scroll[layer.plane][0] >> 16),
+			top + layer.y - (_scroll[layer.plane][1] >> 16),
+			Common::Rect(0, 0, layer.width, layer.height));
 }
 
 void RA2PSXLevel2Scene::drawFrame(Graphics::Surface &surface, const RA2PSXTexture &frame,
@@ -253,38 +487,76 @@ void RA2PSXLevel2Scene::drawFrame(Graphics::Surface &surface, const RA2PSXTextur
 	}
 }
 
+void RA2PSXLevel2Scene::drawPlayFrame(Graphics::Surface &surface,
+		const RA2PSXPlayFrame &frame, const Common::Array<uint32> &palette,
+		int left, int top) const {
+	for (int row = 0; row < frame.height; ++row) {
+		const int destY = top + frame.y + row;
+		if (destY < 0 || destY >= surface.h)
+			continue;
+		for (int column = 0; column < frame.width; ++column) {
+			const int destX = left + frame.x + column;
+			if (destX < 0 || destX >= surface.w)
+				continue;
+			const byte index = frame.pixels[row * frame.width + column];
+			const uint32 pixel = index < palette.size() ? palette[index] : 0;
+			if (!(pixel & 0x01000000))
+				continue;
+			surface.setPixel(destX, destY, surface.format.RGBToColor(
+					(pixel >> 16) & 0xff, (pixel >> 8) & 0xff, pixel & 0xff));
+		}
+	}
+}
+
 void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) const {
 	const RA2PSXLevel2PartInfo &part = info();
 	const int left = (surface.w - 320) / 2;
-	const int top = (surface.h - 240) / 2 + kRA2PSXLevel2SceneTop;
-	const int backX = _scroll[0][0] >> 16;
-	const int paraX = _scroll[1][0] >> 16;
+	const int top = (surface.h - 240) / 2;
 	surface.fillRect(Common::Rect(surface.w, surface.h), 0);
 
-	drawLayer(surface, "BACK_L", left - backX, top);
-	drawLayer(surface, "BACK_R", left + 256 - backX, top);
-	drawLayer(surface, "PARA_L", left - paraX, top);
-	if (part.hasMiddleStrip)
-		drawLayer(surface, "PARA_M", left + 0x36 - paraX, top);
-	drawLayer(surface, "PARA_R", left + 0x36 - paraX, top);
+	for (int i = 0; i < kRA2PSXLevel2LayerCount; ++i)
+		drawLayer(surface, part.layers[i], left, top);
 
 	if ((uint)_frame < _rookie.size()) {
 		const RA2PSXLevel2Pose &pose = part.poses[_frame];
 		drawFrame(surface, _rookie[_frame], left + pose.x + part.rookieOffsetX,
-				(surface.h - 240) / 2 + pose.y + part.rookieOffsetY);
+				top + pose.y + part.rookieOffsetY);
+	}
+
+	// Troopers ride the backdrop, so ducking slides them behind the near wall.
+	const int actorX = left - (_scroll[0][0] >> 16);
+	const int actorY = top + kRA2PSXLevel2SceneTop - (_scroll[0][1] >> 16);
+	for (int i = 0; i < part.trooperCount; ++i) {
+		const Common::Array<uint32> *palette = _textures.palette(part.trooperPalettes[i]);
+		if (!palette)
+			continue;
+		if (_troopers[i].state != kRA2PSXLevel2StateIdle &&
+				_troopers[i].state != kRA2PSXLevel2StateCover) {
+			const RA2PSXPlayFrame *frame = actorFrame(_troopers[i]);
+			if (frame)
+				drawPlayFrame(surface, *frame, *palette, actorX, actorY);
+		}
+		const Common::Array<uint32> *bolt = _textures.palette(part.boltPalettes[i]);
+		if (bolt && _bolts[i].state != kRA2PSXLevel2StateIdle) {
+			const RA2PSXPlayFrame *frame = actorFrame(_bolts[i]);
+			if (frame)
+				drawPlayFrame(surface, *frame, *bolt, actorX, actorY);
+		}
 	}
 
 	if (outOfCover() && _hud.has("CROSS"))
-		_hud.draw(surface, "CROSS", left + aimX - 4,
-				(surface.h - 240) / 2 + aimY - 3, Common::Rect(0, 0, 8, 7));
+		_hud.draw(surface, "CROSS", left + aimX - 4, top + aimY - 3,
+				Common::Rect(0, 0, 8, 7));
 }
 
 // The PSX release composites its waves from sprite sheets instead of playing a SMUSH
-// segment, but the level's shape is the same, so it rides runRebel2Level2 as well.
+// segment, but the level's shape is the same, so it rides runRebel2Level2 as well. Each
+// wave is one pass at the part's troopers; the part is over once its budget is spent.
 class Rebel2PSX::Level2Handler : public Rebel2Level2Handler {
 public:
 	Level2Handler(Rebel2PSX &psx, const RA2PSXArchive &archive) :
-		_psx(psx), _archive(archive), _phaseState(0) {
+		_psx(psx), _archive(archive), _phaseState(0), _shield(kRA2PSXShieldFull),
+		_score(0), _kills(0), _misses(0), _lives(3), _skipped(false) {
 	}
 
 	bool shouldQuit() const override { return _psx._vm->shouldQuit(); }
@@ -293,28 +565,44 @@ public:
 		return _psx.playVideo("S1/L02_INTR.STR", 1, false);
 	}
 
-	void beginAttempt() override {}
+	void beginAttempt() override {
+		_shield = kRA2PSXShieldFull;
+		_kills = 0;
+		_misses = 0;
+	}
 
 	void beginPhase(int phase, bool) override {
 		_phaseState = 0;
-		if (!_scene.load(_archive, phase - 1))
+		_skipped = false;
+		if (!_scene.load(_archive, phase - 1, _psx._settings.difficulty, _psx._vm->_rnd))
 			warning("Rebel Assault II: could not load level 2 part %d", phase);
 	}
 
+	// One wave per trooper the part scripts, so the shared loop runs the whole part.
 	int16 waveBudget(int phase) override {
-		// The per difficulty budget table is not read out of the PSX build yet.
-		return (int16)(4 + phase);
+		const int16 *wave = kRA2PSXLevel2WaveTable[CLIP(phase - 1, 0, 2)]
+				[CLIP(_psx._settings.difficulty, 0, 2)];
+		return (int16)(wave[0] + wave[1]);
 	}
 
 	bool playBackgroundWave(int phase) override { return showScene(phase); }
 	bool playWave(int phase, uint16) override { return showScene(phase); }
 
-	WaveCredit creditWave(int16, int16 *, int16) override {
-		// Nothing decodes r1L2P* or play* yet, so every phase reports itself finished
-		// rather than looping on waves that cannot be built.
+	// The PSX part is one continuous scene rather than a chain of movie waves, so a pass
+	// normally settles the phase outright. The budget only matters if one is cut short.
+	WaveCredit creditWave(int16 mask, int16 *budget, int16) override {
 		WaveCredit credit;
-		credit.stop = true;
-		_phaseState = 0x0e;
+		if (_scene.cleared() || playerDead() || _skipped) {
+			_phaseState = (uint16)mask;
+			credit.stop = true;
+			return credit;
+		}
+		if (budget && *budget > 0) {
+			--*budget;
+			credit.bits = 2;
+		} else {
+			credit.stop = true;
+		}
 		return credit;
 	}
 
@@ -326,12 +614,20 @@ public:
 	}
 
 	uint16 phaseState() const override { return _phaseState; }
-	bool playerDead() const override { return false; }
-	void accumulateKills() override {}
-	void accumulateMisses() override {}
+	bool playerDead() const override { return _shield <= 0; }
+	void accumulateKills() override { _kills += _scene.kills(); }
+	void accumulateMisses() override { _misses += _scene.misses(); }
 
+	// A death costs a life and restarts the level from its first part.
 	bool handleDeath(int, Result &result) override {
-		result = kQuit;
+		if (!_psx.playVideo("S1/L02_DIE.STR", 1, false)) {
+			result = shouldQuit() ? kQuit : kError;
+			return false;
+		}
+		if (--_lives > 0)
+			return true;
+		_psx.playVideo("S1/L02_OVER.STR", 1, false);
+		result = kGameOver;
 		return false;
 	}
 
@@ -341,8 +637,8 @@ public:
 	}
 
 private:
-	// Runs the part. The rookie leans out and ducks back, the crosshair picks one of the
-	// twenty five aim poses, and the walls slide between the two cover positions.
+	// Runs one pass at the part: the rookie leans out and ducks, the scripted troopers
+	// step out and trade fire, and the pass ends when the wave or the shield runs out.
 	bool showScene(int phase) {
 		const RA2PSXLevel2PartInfo &part = kRA2PSXLevel2Parts[phase - 1];
 		const bool cursorWasVisible = CursorMan.isVisible();
@@ -352,18 +648,25 @@ private:
 		int aimY = (part.aimTop + part.aimBottom) / 2;
 		bool left = false, right = false, up = false, down = false;
 		bool running = true;
+		int flashFrame = kRA2PSXHitFlashFrames;
+		int tick = -1;
+		const uint32 startTime = g_system->getMillis();
 		const int viewX = ((int)_psx._vm->_screenWidth - 320) / 2;
 		const int viewY = ((int)_psx._vm->_screenHeight - 240) / 2;
 
 		while (running && !shouldQuit()) {
+			bool shoot = false;
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				const bool pressed = event.type == Common::EVENT_KEYDOWN;
 				if (pressed || event.type == Common::EVENT_KEYUP) {
 					switch (event.kbd.keycode) {
 					case Common::KEYCODE_ESCAPE:
-						if (pressed)
+						// Stands in for the pause menu, which is not ported yet.
+						if (pressed) {
+							_skipped = true;
 							running = false;
+						}
 						break;
 					case Common::KEYCODE_LEFT:
 					case Common::KEYCODE_a:
@@ -381,11 +684,14 @@ private:
 					case Common::KEYCODE_s:
 						down = pressed;
 						break;
-					case Common::KEYCODE_SPACE:
 					case Common::KEYCODE_RETURN:
 						if (pressed && !event.kbdRepeat)
 							_scene.toggleCover();
 						break;
+					case Common::KEYCODE_SPACE:
+						if (pressed && !event.kbdRepeat)
+							shoot = true;
+						break;
 					default:
 						break;
 					}
@@ -395,32 +701,54 @@ private:
 				} else if (event.type == Common::EVENT_RBUTTONDOWN) {
 					_scene.toggleCover();
 				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-					if (!_scene.outOfCover())
-						_scene.toggleCover();
+					shoot = true;
 				} else if (event.type == Common::EVENT_QUIT ||
 						event.type == Common::EVENT_RETURN_TO_LAUNCHER) {
 					_psx._vm->quitGame();
 				}
 			}
 
-			// Aiming only moves while the rookie is actually out of cover.
-			if (_scene.outOfCover()) {
-				aimX += (right ? 3 : 0) - (left ? 3 : 0);
-				aimY += (down ? 3 : 0) - (up ? 3 : 0);
+			const uint32 elapsed = g_system->getMillis() - startTime;
+			const int target = (int)((uint64)elapsed * kRA2PSXLevel2TickRate / 1000);
+			bool redraw = false;
+			while (tick < target && running) {
+				++tick;
+				redraw = true;
+				if (_scene.outOfCover()) {
+					aimX += (right ? 2 : 0) - (left ? 2 : 0);
+					aimY += (down ? 2 : 0) - (up ? 2 : 0);
+				}
+				aimX = CLIP<int>(aimX, part.aimLeft, part.aimRight);
+				aimY = CLIP<int>(aimY, part.aimTop, part.aimBottom);
+				_scene.update(aimX, aimY);
+
+				const int damage = _scene.updateEnemies(_psx._vm->_rnd);
+				if (damage) {
+					_shield = MAX(0, _shield - damage);
+					flashFrame = 0;
+				}
+				if (shoot) {
+					_score += _scene.shoot(aimX, aimY);
+					shoot = false;
+				}
+				if (flashFrame < kRA2PSXHitFlashFrames)
+					++flashFrame;
+				if (_shield <= 0 || _scene.cleared())
+					running = false;
+			}
+
+			if (redraw) {
+				Graphics::Surface output;
+				output.create(_psx._vm->_screenWidth, _psx._vm->_screenHeight,
+						g_system->getScreenFormat());
+				_scene.draw(output, aimX, aimY);
+				drawRA2PSXHitFlash(output, flashFrame);
+				g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0,
+						output.w, output.h);
+				output.free();
+				g_system->updateScreen();
 			}
-			aimX = CLIP<int>(aimX, part.aimLeft, part.aimRight);
-			aimY = CLIP<int>(aimY, part.aimTop, part.aimBottom);
-			_scene.update(aimX, aimY);
-
-			Graphics::Surface output;
-			output.create(_psx._vm->_screenWidth, _psx._vm->_screenHeight,
-					g_system->getScreenFormat());
-			_scene.draw(output, aimX, aimY);
-			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0,
-					output.w, output.h);
-			output.free();
-			g_system->updateScreen();
-			g_system->delayMillis(20);
+			g_system->delayMillis(5);
 		}
 
 		CursorMan.showMouse(cursorWasVisible);
@@ -431,6 +759,12 @@ private:
 	const RA2PSXArchive &_archive;
 	RA2PSXLevel2Scene _scene;
 	uint16 _phaseState;
+	int _shield;
+	int _score;
+	int _kills;
+	int _misses;
+	int _lives;
+	bool _skipped;
 };
 
 Common::Error Rebel2PSX::runLevel2() {
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index e29d8b55e80..b8ebb562bea 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -124,6 +124,16 @@ void RA2PSXMatrix::setTranslation(int x, int y, int z) {
 	translation[2] = (float)z;
 }
 
+// A 16 bit GPU colour, unpacked to opaque bit, semi transparency bit and 8 bit channels.
+uint32 decodeRA2PSXColor(uint16 value) {
+	if (!value)
+		return 0;
+	const uint32 r = ((value & 0x1f) << 3) | ((value & 0x1f) >> 2);
+	const uint32 g = (((value >> 5) & 0x1f) << 3) | (((value >> 5) & 0x1f) >> 2);
+	const uint32 b = (((value >> 10) & 0x1f) << 3) | (((value >> 10) & 0x1f) >> 2);
+	return 0x01000000 | ((value & 0x8000) ? 0x02000000 : 0) | (r << 16) | (g << 8) | b;
+}
+
 bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		Common::Array<RA2PSXTexture> &textures) {
 	const uint initialCount = textures.size();
@@ -157,21 +167,15 @@ bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		texture.name = name;
 		texture.width = width;
 		texture.height = height;
+		texture.palette.resize(paletteColors);
+		for (uint32 i = 0; i < paletteColors; ++i)
+			texture.palette[i] = decodeRA2PSXColor(
+					READ_LE_UINT16(data.data() + paletteOffset + i * 2));
 		texture.pixels.resize(pixelCount);
 		for (uint32 i = 0; i < pixelCount; ++i) {
 			const byte packed = data[pixelsOffset + (eightBit ? i : i / 2)];
 			const byte paletteIndex = eightBit ? packed : ((i & 1) ? packed >> 4 : packed & 0xf);
-			const uint16 value = READ_LE_UINT16(data.data() + paletteOffset + paletteIndex * 2);
-			if (!value) {
-				texture.pixels[i] = 0;
-				continue;
-			}
-
-			const uint32 r = ((value & 0x1f) << 3) | ((value & 0x1f) >> 2);
-			const uint32 g = (((value >> 5) & 0x1f) << 3) | (((value >> 5) & 0x1f) >> 2);
-			const uint32 b = (((value >> 10) & 0x1f) << 3) | (((value >> 10) & 0x1f) >> 2);
-			texture.pixels[i] = 0x01000000 | ((value & 0x8000) ? 0x02000000 : 0) |
-					(r << 16) | (g << 8) | b;
+			texture.pixels[i] = texture.palette[paletteIndex];
 		}
 		textures.push_back(texture);
 		offset += recordSize;
@@ -218,6 +222,80 @@ bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeig
 	return true;
 }
 
+// The playN scripts stream their frames through one byte oriented RLE: a zero ends the
+// stream, a count with the top bit set repeats the next byte, and any other count copies
+// that many literals.
+bool decodeRA2PSXPlayRun(const Common::Array<byte> &data, uint32 &offset,
+		uint32 expected, Common::Array<byte> &pixels) {
+	pixels.clear();
+	pixels.reserve(expected);
+	while (offset < data.size()) {
+		const byte count = data[offset++];
+		if (!count)
+			return pixels.size() == expected;
+		if (count & 0x80) {
+			if (offset >= data.size())
+				return false;
+			const byte value = data[offset++];
+			for (int i = (count & 0x7f) + 1; i > 0; --i)
+				pixels.push_back(value);
+			continue;
+		}
+		if (offset + count > data.size())
+			return false;
+		for (int i = 0; i < count; ++i)
+			pixels.push_back(data[offset++]);
+	}
+	return false;
+}
+
+bool loadRA2PSXPlayScript(const Common::Array<byte> &data,
+		Common::Array<RA2PSXPlayAnimation> &animations) {
+	animations.clear();
+	if (data.size() < 4)
+		return false;
+
+	// The file opens with a table of animation offsets that runs up to the first one.
+	const uint32 first = READ_LE_UINT32(data.data());
+	if (first < 4 || first > data.size() || (first & 3))
+		return false;
+
+	animations.resize(first / 4);
+	for (uint index = 0; index < animations.size(); ++index) {
+		uint32 offset = READ_LE_UINT32(data.data() + index * 4);
+		while (offset && offset + 16 <= data.size()) {
+			RA2PSXPlayFrame frame;
+			const uint16 recordSize = READ_LE_UINT16(data.data() + offset);
+			frame.x = READ_LE_INT16(data.data() + offset + 2);
+			frame.y = READ_LE_INT16(data.data() + offset + 4);
+			frame.width = data[offset + 6];
+			frame.height = data[offset + 7];
+			frame.boxLeft = frame.x + (int8)data[offset + 8];
+			frame.boxTop = frame.y + (int8)data[offset + 9];
+			frame.boxRight = frame.boxLeft + (int8)data[offset + 10];
+			frame.boxBottom = frame.boxTop + (int8)data[offset + 11];
+			frame.flags = READ_LE_UINT16(data.data() + offset + 12);
+
+			const uint32 pixelCount = (uint32)frame.width * frame.height;
+			uint32 cursor = offset + 16;
+			if (frame.flags & kRA2PSXPlayCompressed) {
+				if (!decodeRA2PSXPlayRun(data, cursor, pixelCount, frame.pixels))
+					return false;
+			} else {
+				if (cursor + pixelCount > data.size())
+					return false;
+				frame.pixels.resize(pixelCount);
+				memcpy(frame.pixels.data(), data.data() + cursor, pixelCount);
+			}
+			animations[index].push_back(frame);
+			if ((frame.flags & kRA2PSXPlayLastFrame) || !recordSize)
+				break;
+			offset += recordSize;
+		}
+	}
+	return true;
+}
+
 RA2PSXModel::RA2PSXModel() : _radius(1.0f) {
 }
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 48c6224f886..2cb07b77859 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -203,11 +203,47 @@ struct RA2PSXTexture {
 	uint16 width;
 	uint16 height;
 	Common::Array<uint32> pixels;
+	// The resolved CLUT, kept so sprites streamed into this texture's VRAM slot can
+	// borrow its colours the way the original's fixed page assignment does.
+	Common::Array<uint32> palette;
 };
 
+uint32 decodeRA2PSXColor(uint16 value);
 bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		Common::Array<RA2PSXTexture> &textures);
 
+// One frame of a playN script: where it lands on screen, its 8 bit pixels, and the flags
+// that say whether it can be shot, whether it hurts the player, and when it makes noise.
+struct RA2PSXPlayFrame {
+	RA2PSXPlayFrame() : x(0), y(0), width(0), height(0), boxLeft(0), boxTop(0),
+			boxRight(0), boxBottom(0), flags(0) {}
+
+	int16 x;
+	int16 y;
+	int16 width;
+	int16 height;
+	int16 boxLeft;
+	int16 boxTop;
+	int16 boxRight;
+	int16 boxBottom;
+	uint16 flags;
+	Common::Array<byte> pixels;
+};
+
+enum {
+	kRA2PSXPlayLastFrame = 0x0001,
+	kRA2PSXPlayCompressed = 0x0002,
+	kRA2PSXPlayTargetable = 0x0004,
+	kRA2PSXPlayHitsPlayer = 0x0008,
+	kRA2PSXPlaySound = 0x0010
+};
+
+typedef Common::Array<RA2PSXPlayFrame> RA2PSXPlayAnimation;
+
+// playN: a table of animation offsets, each opening a chain of frame records.
+bool loadRA2PSXPlayScript(const Common::Array<byte> &data,
+		Common::Array<RA2PSXPlayAnimation> &animations);
+
 // The fireball frames are 68x56, the size the original's VRAM upload uses.
 enum { kRA2PSXExplosionHeight = 56 };
 
@@ -218,6 +254,7 @@ bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeig
 // Level 2's three parts, each a sheet of backdrop halves, parallax walls and actors.
 enum {
 	kRA2PSXLevel2PartCount = 3,
+	kRA2PSXLevel2LayerCount = 5,
 	kRA2PSXLevel2SceneTop = 28,
 	// Frames 0 to 4 are the rookie leaning out of cover; 5 to 29 are a five by five
 	// grid of aim poses picked from where the crosshair sits.
@@ -226,8 +263,54 @@ enum {
 	kRA2PSXLevel2RookieDelay = 3,
 	kRA2PSXLevel2AimColumns = 5,
 	kRA2PSXLevel2AimRows = 5,
-	// The scroll eases to its target over this many ticks.
-	kRA2PSXLevel2ScrollSteps = 20
+	// The slide runs at a constant speed of distance/20 for 14 ticks, then decays by
+	// 0xe000/0x10000 a tick until it lands - ra2 eases out rather than lerping.
+	kRA2PSXLevel2ScrollSteps = 20,
+	kRA2PSXLevel2ScrollHold = 14,
+	kRA2PSXLevel2ScrollDamping = 0xe000
+};
+
+enum {
+	kRA2PSXLevel2TrooperCount = 3,
+	// The gameplay clock is the 60Hz vblank, and a play script steps every fourth tick.
+	kRA2PSXLevel2TickRate = 60,
+	kRA2PSXLevel2FrameTicks = 4,
+	// A part ends a second after its last trooper falls.
+	kRA2PSXLevel2ClearTicks = 60,
+	// A trooper hides behind its own sprite until its slot has counted down.
+	kRA2PSXLevel2SlotNever = 0x7fffffff
+};
+
+// The states a play script actor walks, named after the values the original stores.
+enum {
+	kRA2PSXLevel2StateIdle = 0,
+	kRA2PSXLevel2StateAppear = 1,
+	kRA2PSXLevel2StateAimed = 2,
+	kRA2PSXLevel2StateWild = 3,
+	kRA2PSXLevel2StateDie = 4,
+	kRA2PSXLevel2StateShot = 5,
+	kRA2PSXLevel2StateCover = 0x80
+};
+
+// One play script actor: a trooper, or the bolt it fires.
+struct RA2PSXLevel2Actor {
+	RA2PSXLevel2Actor() : state(kRA2PSXLevel2StateIdle), animation(-1), frame(0), hold(0),
+			hit(false), slotTick(0), fireNext(0), fireEnd(0) {}
+
+	int state;
+	int animation;
+	int frame;
+	int hold;
+	bool hit;
+	int slotTick;
+	int fireNext;
+	int fireEnd;
+};
+
+// The frame window a trooper shoots inside, per part, per trooper, per animation.
+struct RA2PSXLevel2FireWindow {
+	int16 start;
+	int16 end;
 };
 
 // Where each rookie frame is drawn, straight out of the original's per level table.
@@ -238,21 +321,54 @@ struct RA2PSXLevel2Pose {
 	int16 height;
 };
 
+// One backdrop sprite, as the original's display list holds it: a fixed screen position
+// the scroll is subtracted from, and the size the SPRT draws rather than the sheet's.
+struct RA2PSXLevel2Layer {
+	const char *name;
+	int16 x;
+	int16 y;
+	int16 width;
+	int16 height;
+	// 0 rides the backdrop scroll, 1 the nearer parallax one.
+	byte plane;
+};
+
 struct RA2PSXLevel2PartInfo {
 	const char *sheet;
 	const char *anims;
-	int backScroll;
-	int paraScroll;
+	// Where each plane sits in cover and out in the open, as 16.16 pixels.
+	int16 coverScroll[2][2];
+	int16 openScroll[2][2];
+	// Which axis eases out; the other one holds its speed all the way.
+	byte scrollAxis;
+	RA2PSXLevel2Layer layers[kRA2PSXLevel2LayerCount];
 	int rookieOffsetX;
 	int rookieOffsetY;
-	bool hasMiddleStrip;
 	// Crosshair box, then the thresholds that split it into the aim grid.
 	int16 aimLeft, aimTop, aimRight, aimBottom;
 	int16 aimColumns[kRA2PSXLevel2AimColumns];
 	int16 aimRows[kRA2PSXLevel2AimRows];
 	RA2PSXLevel2Pose poses[kRA2PSXLevel2FrameCount];
+	// How many trooper slots the part scripts, and the sheet sprites whose palettes the
+	// streamed trooper and bolt frames borrow.
+	int trooperCount;
+	const char *trooperPalettes[kRA2PSXLevel2TrooperCount];
+	const char *boltPalettes[kRA2PSXLevel2TrooperCount];
+	// Empty windows mean the part fires as it changes state instead of on a frame count.
+	RA2PSXLevel2FireWindow fireWindows[kRA2PSXLevel2TrooperCount][3];
 };
 
+// Waves per part by difficulty, and the timings and odds that drive a trooper's slot.
+extern const int16 kRA2PSXLevel2WaveTable[kRA2PSXLevel2PartCount][3][2];
+// Per slot: how long until it reappears, the odds it aims rather than sprays, and how
+// long a killed trooper's spot stays empty.
+extern const int16 kRA2PSXLevel2SlotTable[kRA2PSXLevel2PartCount][3][5];
+// How many animation frames pass between two shots. Part 3 fires on state changes only.
+extern const int16 kRA2PSXLevel2FireTable[2][3][2];
+// The odds an enemy bolt connects, and what it takes off the shield when it does.
+extern const int16 kRA2PSXLevel2BoltTable[kRA2PSXLevel2PartCount][3][2];
+extern const int16 kRA2PSXLevel2KillScore[3];
+
 extern const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount];
 
 // How far the level 1 background is panned and tilted inside its oversized frame, and the
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index a3999ab76f9..d2a696856c7 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -76,6 +76,13 @@ bool RA2PSXTextureSet::appendRaw24(const char *name, const Common::Array<byte> &
 	return true;
 }
 
+const Common::Array<uint32> *RA2PSXTextureSet::palette(const char *name) const {
+	if (!name)
+		return nullptr;
+	const RA2PSXTexture *texture = find(name);
+	return texture && !texture->palette.empty() ? &texture->palette : nullptr;
+}
+
 const RA2PSXTexture *RA2PSXTextureSet::find(const char *name) const {
 	for (uint i = 0; i < _textures.size(); ++i) {
 		if (_textures[i].name.equalsIgnoreCase(name))
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 1d85fa398d2..3ae06f51375 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -13,6 +13,7 @@
 #ifndef SCUMM_INSANE_REBEL2_PSX_UI_H
 #define SCUMM_INSANE_REBEL2_PSX_UI_H
 
+#include "common/random.h"
 #include "common/rect.h"
 
 #include "graphics/surface.h"
@@ -35,6 +36,8 @@ public:
 	bool appendRaw24(const char *name, const Common::Array<byte> &data,
 			uint16 width, uint16 height);
 	bool has(const char *name) const { return find(name) != nullptr; }
+	// Sprites streamed into a texture's VRAM slot are drawn through its CLUT.
+	const Common::Array<uint32> *palette(const char *name) const;
 	void draw(Graphics::Surface &surface, const char *name, int x, int y,
 			const Common::Rect &source, int brightness = 0x80,
 			BlendMode blend = kBlendOpaque) const;
@@ -53,31 +56,51 @@ private:
 };
 
 // One part of level 2: the backdrop halves and parallax walls scroll behind the rookie,
-// who stands centred and ducks in and out of cover.
+// who stands centred and ducks in and out of cover while scripted troopers trade fire.
 class RA2PSXLevel2Scene {
 public:
 	RA2PSXLevel2Scene();
 
-	bool load(const RA2PSXArchive &archive, int part);
+	bool load(const RA2PSXArchive &archive, int part, int difficulty,
+			Common::RandomSource &random);
 	// Toggles between cover and the open; ignored while a move is already running.
 	void toggleCover();
 	void update(int aimX, int aimY);
+	// One 60Hz tick of the trooper slots. Returns the shield damage it cost the player.
+	int updateEnemies(Common::RandomSource &random);
+	// Kills whatever the crosshair covers; returns the score it earned, or zero.
+	int shoot(int aimX, int aimY);
 	bool outOfCover() const { return _out && !_moving; }
+	// Anything but fully ducked; the original only shields a rookie who has finished.
+	bool exposed() const { return _out || _moving; }
 	bool busy() const { return _moving; }
+	bool cleared() const { return _clearTicks >= kRA2PSXLevel2ClearTicks; }
+	int kills() const { return _kills; }
+	int misses() const { return _misses; }
 	void draw(Graphics::Surface &surface, int aimX, int aimY) const;
 	const RA2PSXLevel2PartInfo &info() const;
 
 private:
-	void drawLayer(Graphics::Surface &surface, const char *name, int x, int y) const;
+	void drawLayer(Graphics::Surface &surface, const RA2PSXLevel2Layer &layer,
+			int left, int top) const;
 	void drawFrame(Graphics::Surface &surface, const RA2PSXTexture &frame,
 			int x, int y) const;
-	void setScrollTarget(int backX, int backY, int paraX, int paraY);
+	void drawPlayFrame(Graphics::Surface &surface, const RA2PSXPlayFrame &frame,
+			const Common::Array<uint32> &palette, int left, int top) const;
+	void setScrollTarget(const int16 target[2][2]);
 	int aimFrame(int aimX, int aimY) const;
+	// Restarts an actor on the given animation, the way the original's loader does.
+	void startActor(RA2PSXLevel2Actor &actor, int state, int animation);
+	const RA2PSXPlayFrame *actorFrame(const RA2PSXLevel2Actor &actor) const;
+	void advanceActor(RA2PSXLevel2Actor &actor);
+	int slotDelay(int base, int range, Common::RandomSource &random) const;
 
 	RA2PSXTextureSet _textures;
 	RA2PSXTextureSet _hud;
 	Common::Array<RA2PSXTexture> _rookie;
+	Common::Array<RA2PSXPlayAnimation> _play;
 	int _part;
+	int _difficulty;
 	int _frame;
 	int _delay;
 	bool _out;
@@ -86,6 +109,16 @@ private:
 	int _scroll[2][2];
 	int _scrollTarget[2][2];
 	int _scrollStep[2][2];
+	int _scrollHold[2][2];
+	// The trooper slots, the bolt each one has in the air, and the wave they draw from.
+	RA2PSXLevel2Actor _troopers[kRA2PSXLevel2TrooperCount];
+	RA2PSXLevel2Actor _bolts[kRA2PSXLevel2TrooperCount];
+	int _tick;
+	int _remaining;
+	int _active;
+	int _clearTicks;
+	int _kills;
+	int _misses;
 };
 
 // The GPU's semi-transparency mode 2: background minus source.


Commit: 53a736900d2c8de823b59f91bf4b6c7f06d0e8bd
    https://github.com/scummvm/scummvm/commit/53a736900d2c8de823b59f91bf4b6c7f06d0e8bd
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: fix L2 aiming, occlusion and per-part rookie tables in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level2.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
index 23e2a7010ec..9515582b77e 100644
--- a/engines/scumm/insane/rebel2/psx/level2.cpp
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -48,7 +48,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		  { "PARA_R", 234, 28, 190, 190, 1 } },
 		0, 0x26,
 		0x2a, 0x32, 0xcf, 0x8d,
-		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 }, 0,
 		{
 		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
 		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
@@ -74,18 +74,18 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		  { nullptr,    0,   0,   0,   0, 0 } },
 		-10, 0x12,
 		0x1c, 0x26, 0x11e, 0xa0,
-		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{ 59, 125, 190, 257, 320 }, { 60, 90, 120, 138, 240 }, 0,
 		{
-		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
-		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
-		{ 169, 73, 88, 108 }, { 167, 73, 90, 108 }, { 165, 73, 92, 108 },
-		{ 165, 73, 92, 108 }, { 179, 73, 78, 108 }, { 179, 73, 78, 108 },
-		{ 178, 73, 80, 108 }, { 174, 74, 84, 107 }, { 174, 74, 84, 107 },
-		{ 181, 73, 78, 109 }, { 181, 73, 78, 109 }, { 181, 73, 78, 108 },
-		{ 181, 73, 80, 108 }, { 181, 73, 80, 108 }, { 180, 73, 80, 108 },
-		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
-		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
-		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
+		{ 146, 150, 68, 50 }, { 140, 141, 74, 59 }, { 147, 112, 70, 88 },
+		{ 156, 106, 70, 94 }, { 164, 106, 70, 94 }, { 139, 106, 90, 94 },
+		{ 134, 113, 94, 87 }, { 132, 114, 96, 86 }, { 131, 115, 96, 85 },
+		{ 124, 118, 102, 82 }, { 157, 103, 76, 97 }, { 155, 113, 76, 87 },
+		{ 155, 114, 76, 86 }, { 154, 115, 76, 85 }, { 144, 117, 86, 83 },
+		{ 160, 103, 72, 97 }, { 159, 109, 74, 91 }, { 161, 111, 72, 89 },
+		{ 160, 112, 72, 88 }, { 161, 113, 72, 87 }, { 157, 106, 72, 94 },
+		{ 157, 108, 74, 92 }, { 158, 110, 72, 90 }, { 157, 111, 72, 89 },
+		{ 156, 112, 72, 88 }, { 159, 106, 72, 94 }, { 158, 112, 76, 88 },
+		{ 160, 112, 78, 88 }, { 159, 113, 80, 87 }, { 160, 115, 80, 85 }
 		},
 		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
 		{ { { 0x12, 0x2b }, { 0x0e, 0x24 }, { 0x0e, 0x19 } },
@@ -100,18 +100,19 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		  { nullptr,    0,  0,   0,   0, 0 } },
 		0, 0x23,
 		0x1a, 0x43, 0xc9, 0x92,
-		{ 63, 104, 145, 186, 320 }, { 73, 88, 98, 118, 240 },
+		{ 48, 91, 134, 177, 320 }, { 77, 87, 106, 126, 240 }, 3,
 		{
-		{ 255, 65, 78, 116 }, { 250, 68, 76, 113 }, { 234, 68, 76, 113 },
-		{ 219, 68, 70, 113 }, { 200, 72, 80, 109 }, { 169, 73, 88, 108 },
-		{ 169, 73, 88, 108 }, { 167, 73, 90, 108 }, { 165, 73, 92, 108 },
-		{ 165, 73, 92, 108 }, { 179, 73, 78, 108 }, { 179, 73, 78, 108 },
-		{ 178, 73, 80, 108 }, { 174, 74, 84, 107 }, { 174, 74, 84, 107 },
-		{ 181, 73, 78, 109 }, { 181, 73, 78, 109 }, { 181, 73, 78, 108 },
-		{ 181, 73, 80, 108 }, { 181, 73, 80, 108 }, { 180, 73, 80, 108 },
-		{ 180, 73, 80, 108 }, { 181, 73, 82, 108 }, { 182, 73, 80, 108 },
-		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
-		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
+		{ 147, 103, 90, 81 }, { 156, 100, 78, 84 }, { 150, 99, 64, 85 },
+		{ 127, 98, 74, 86 }, { 121, 97, 74, 87 }, { 107, 101, 82, 83 },
+		{ 74, 101, 96, 83 }, { 81, 92, 86, 92 }, { 91, 95, 74, 89 },
+		{ 90, 99, 74, 85 }, { 92, 103, 74, 81 }, { 91, 103, 74, 81 },
+		{ 89, 102, 74, 82 }, { 91, 94, 76, 90 }, { 90, 99, 74, 85 },
+		{ 88, 103, 74, 81 }, { 89, 103, 74, 81 }, { 87, 102, 74, 82 },
+		{ 90, 95, 72, 89 }, { 92, 99, 70, 85 }, { 91, 102, 72, 82 },
+		{ 91, 102, 72, 82 }, { 90, 102, 72, 82 }, { 92, 95, 72, 89 },
+		{ 92, 100, 76, 84 }, { 89, 103, 78, 81 }, { 91, 103, 78, 81 },
+		{ 90, 102, 76, 82 }, { 93, 95, 90, 89 }, { 95, 101, 90, 83 },
+		{ 95, 103, 90, 81 }, { 95, 103, 92, 81 }, { 93, 103, 90, 81 }
 		},
 		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
 		{ { { 0, 0 }, { 0, 0 }, { 0, 0 } },
@@ -179,7 +180,8 @@ bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part, int difficu
 
 	// Every rookie pose is its own member: a format word, a palette and one image.
 	_rookie.clear();
-	for (int frame = 0; frame < kRA2PSXLevel2FrameCount; ++frame) {
+	const int poseCount = kRA2PSXLevel2Parts[part].aimBase + 30;
+	for (int frame = 0; frame < poseCount; ++frame) {
 		const Common::String path = Common::String::format("%s/anim%02d",
 				kRA2PSXLevel2Parts[part].anims, frame);
 		Common::Array<byte> anim;
@@ -212,8 +214,9 @@ bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part, int difficu
 	const RA2PSXLevel2PartInfo &part_ = kRA2PSXLevel2Parts[part];
 	for (int plane = 0; plane < 2; ++plane) {
 		for (int axis = 0; axis < 2; ++axis) {
+			// Multiply rather than shift: parts two and three scroll to negatives.
 			_scroll[plane][axis] = _scrollTarget[plane][axis] =
-					part_.coverScroll[plane][axis] << 16;
+					part_.coverScroll[plane][axis] * 0x10000;
 			_scrollStep[plane][axis] = _scrollHold[plane][axis] = 0;
 		}
 	}
@@ -356,6 +359,9 @@ int RA2PSXLevel2Scene::shoot(int aimX, int aimY) {
 	if (!outOfCover())
 		return 0;
 
+	// The crosshair is in screen space; a frame's box is where the backdrop puts it.
+	const int shotX = aimX + (_scroll[0][0] >> 16);
+	const int shotY = aimY - kRA2PSXLevel2SceneTop + (_scroll[0][1] >> 16);
 	for (int i = 0; i < info().trooperCount; ++i) {
 		RA2PSXLevel2Actor &trooper = _troopers[i];
 		if (trooper.state == kRA2PSXLevel2StateIdle ||
@@ -365,8 +371,8 @@ int RA2PSXLevel2Scene::shoot(int aimX, int aimY) {
 		const RA2PSXPlayFrame *frame = actorFrame(trooper);
 		if (!frame || !(frame->flags & kRA2PSXPlayTargetable))
 			continue;
-		if (aimX < frame->boxLeft || aimX > frame->boxRight ||
-				aimY < frame->boxTop || aimY > frame->boxBottom)
+		if (shotX < frame->boxLeft || shotX > frame->boxRight ||
+				shotY < frame->boxTop || shotY > frame->boxBottom)
 			continue;
 
 		startActor(trooper, kRA2PSXLevel2StateDie, i * 8 + 3);
@@ -381,7 +387,7 @@ void RA2PSXLevel2Scene::setScrollTarget(const int16 target[2][2]) {
 	const RA2PSXLevel2PartInfo &part = info();
 	for (int plane = 0; plane < 2; ++plane) {
 		for (int axis = 0; axis < 2; ++axis) {
-			_scrollTarget[plane][axis] = target[plane][axis] << 16;
+			_scrollTarget[plane][axis] = target[plane][axis] * 0x10000;
 			_scrollStep[plane][axis] =
 					(_scrollTarget[plane][axis] - _scroll[plane][axis]) / kRA2PSXLevel2ScrollSteps;
 			// Only the axis the part actually slides along eases out.
@@ -406,8 +412,12 @@ int RA2PSXLevel2Scene::aimFrame(int aimX, int aimY) const {
 	int row = 0;
 	while (row < kRA2PSXLevel2AimRows && part.aimRows[row] < aimY)
 		++row;
-	return CLIP<int>(column * kRA2PSXLevel2AimColumns + row,
-			(int)kRA2PSXLevel2CoverFrames, (int)kRA2PSXLevel2FrameCount - 1);
+	return CLIP<int>(column * kRA2PSXLevel2AimColumns + row + part.aimBase,
+			coverFrames(), part.aimBase + 29);
+}
+
+int RA2PSXLevel2Scene::coverFrames() const {
+	return info().aimBase + kRA2PSXLevel2CoverFrames;
 }
 
 void RA2PSXLevel2Scene::update(int aimX, int aimY) {
@@ -434,7 +444,7 @@ void RA2PSXLevel2Scene::update(int aimX, int aimY) {
 		_delay = kRA2PSXLevel2RookieDelay;
 		// Leaning out walks 0 to 4, ducking back walks it down again.
 		if (!_out) {
-			if (_frame < kRA2PSXLevel2CoverFrames - 1) {
+			if (_frame < coverFrames() - 1) {
 				++_frame;
 			} else {
 				_moving = false;
@@ -442,8 +452,8 @@ void RA2PSXLevel2Scene::update(int aimX, int aimY) {
 				_frame = aimFrame(aimX, aimY);
 			}
 		} else {
-			if (_frame >= kRA2PSXLevel2CoverFrames)
-				_frame = kRA2PSXLevel2CoverFrames - 1;
+			if (_frame >= coverFrames())
+				_frame = coverFrames() - 1;
 			if (_frame > 0) {
 				--_frame;
 			} else {
@@ -514,16 +524,13 @@ void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) con
 	const int top = (surface.h - 240) / 2;
 	surface.fillRect(Common::Rect(surface.w, surface.h), 0);
 
-	for (int i = 0; i < kRA2PSXLevel2LayerCount; ++i)
-		drawLayer(surface, part.layers[i], left, top);
-
-	if ((uint)_frame < _rookie.size()) {
-		const RA2PSXLevel2Pose &pose = part.poses[_frame];
-		drawFrame(surface, _rookie[_frame], left + pose.x + part.rookieOffsetX,
-				top + pose.y + part.rookieOffsetY);
+	for (int i = 0; i < kRA2PSXLevel2LayerCount; ++i) {
+		if (!part.layers[i].plane)
+			drawLayer(surface, part.layers[i], left, top);
 	}
 
-	// Troopers ride the backdrop, so ducking slides them behind the near wall.
+	// Troopers ride the backdrop, so ducking both slides them along and puts the near
+	// wall in front of them.
 	const int actorX = left - (_scroll[0][0] >> 16);
 	const int actorY = top + kRA2PSXLevel2SceneTop - (_scroll[0][1] >> 16);
 	for (int i = 0; i < part.trooperCount; ++i) {
@@ -544,6 +551,18 @@ void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) con
 		}
 	}
 
+	for (int i = 0; i < kRA2PSXLevel2LayerCount; ++i) {
+		if (part.layers[i].plane)
+			drawLayer(surface, part.layers[i], left, top);
+	}
+
+	// The rookie stands nearest of all, in front of his own cover.
+	if ((uint)_frame < _rookie.size()) {
+		const RA2PSXLevel2Pose &pose = part.poses[_frame];
+		drawFrame(surface, _rookie[_frame], left + pose.x + part.rookieOffsetX,
+				top + pose.y + part.rookieOffsetY);
+	}
+
 	if (outOfCover() && _hud.has("CROSS"))
 		_hud.draw(surface, "CROSS", left + aimX - 4, top + aimY - 3,
 				Common::Rect(0, 0, 8, 7));
@@ -648,6 +667,11 @@ private:
 		int aimY = (part.aimTop + part.aimBottom) / 2;
 		bool left = false, right = false, up = false, down = false;
 		bool running = true;
+		// The fire press has to outlive the poll that saw it: ticks run at 60Hz while
+		// events are drained several times as often, so a per pass flag loses presses.
+		bool firePressed = false;
+		bool fireEdge = false;
+		bool fireWasPressed = false;
 		int flashFrame = kRA2PSXHitFlashFrames;
 		int tick = -1;
 		const uint32 startTime = g_system->getMillis();
@@ -655,7 +679,6 @@ private:
 		const int viewY = ((int)_psx._vm->_screenHeight - 240) / 2;
 
 		while (running && !shouldQuit()) {
-			bool shoot = false;
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				const bool pressed = event.type == Common::EVENT_KEYDOWN;
@@ -684,24 +707,43 @@ private:
 					case Common::KEYCODE_s:
 						down = pressed;
 						break;
+					case Common::KEYCODE_SPACE:
 					case Common::KEYCODE_RETURN:
-						if (pressed && !event.kbdRepeat)
-							_scene.toggleCover();
+					case Common::KEYCODE_KP_ENTER:
+						if (pressed && !firePressed && !event.kbdRepeat)
+							fireEdge = true;
+						firePressed = pressed;
 						break;
-					case Common::KEYCODE_SPACE:
+					case Common::KEYCODE_TAB:
+					case Common::KEYCODE_LCTRL:
+					case Common::KEYCODE_RCTRL:
 						if (pressed && !event.kbdRepeat)
-							shoot = true;
+							_scene.toggleCover();
 						break;
 					default:
 						break;
 					}
+				} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START ||
+						event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END) {
+					const bool pressed = event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START;
+					if (event.customType == kScummActionInsaneAttack) {
+						if (pressed && !firePressed)
+							fireEdge = true;
+						firePressed = pressed;
+					} else if (pressed && event.customType == kScummActionInsaneSwitch) {
+						_scene.toggleCover();
+					}
 				} else if (event.type == Common::EVENT_MOUSEMOVE) {
 					aimX = event.mouse.x - viewX;
 					aimY = event.mouse.y - viewY;
 				} else if (event.type == Common::EVENT_RBUTTONDOWN) {
 					_scene.toggleCover();
 				} else if (event.type == Common::EVENT_LBUTTONDOWN) {
-					shoot = true;
+					if (!firePressed)
+						fireEdge = true;
+					firePressed = true;
+				} else if (event.type == Common::EVENT_LBUTTONUP) {
+					firePressed = false;
 				} else if (event.type == Common::EVENT_QUIT ||
 						event.type == Common::EVENT_RETURN_TO_LAUNCHER) {
 					_psx._vm->quitGame();
@@ -727,10 +769,13 @@ private:
 					_shield = MAX(0, _shield - damage);
 					flashFrame = 0;
 				}
-				if (shoot) {
+				// Holding fire keeps shooting, the way the original polls its pad.
+				const bool shoot = fireEdge || (firePressed && fireWasPressed &&
+						!(tick % kRA2PSXLevel2FireRepeat));
+				fireWasPressed = firePressed;
+				fireEdge = false;
+				if (shoot)
 					_score += _scene.shoot(aimX, aimY);
-					shoot = false;
-				}
 				if (flashFrame < kRA2PSXHitFlashFrames)
 					++flashFrame;
 				if (_shield <= 0 || _scene.cleared())
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index b8ebb562bea..fe5aac18b15 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -159,9 +159,14 @@ bool loadRA2PSXTextures(const Common::Array<byte> &data,
 		const uint32 pixelBytes = eightBit ? pixelCount : (pixelCount + 1) / 2;
 		const uint32 paletteOffset = offset + 20;
 		const uint32 pixelsOffset = paletteOffset + paletteColors * 2;
-		if (recordSize < 20 || offset + recordSize > data.size() ||
-				pixelsOffset + pixelBytes > offset + recordSize)
+		if (recordSize < 20 || offset + recordSize > data.size())
 			break;
+		// Some sheets open with a short header record that carries no image; skip it
+		// rather than giving up on the rest of the sheet.
+		if (pixelsOffset + pixelBytes > offset + recordSize) {
+			offset += recordSize;
+			continue;
+		}
 
 		RA2PSXTexture texture;
 		texture.name = name;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 2cb07b77859..cd0ee202f95 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -259,7 +259,8 @@ enum {
 	// Frames 0 to 4 are the rookie leaning out of cover; 5 to 29 are a five by five
 	// grid of aim poses picked from where the crosshair sits.
 	kRA2PSXLevel2CoverFrames = 5,
-	kRA2PSXLevel2FrameCount = 30,
+	// Part three's rookie owns three more poses, and shifts its aim grid past them.
+	kRA2PSXLevel2FrameCount = 33,
 	kRA2PSXLevel2RookieDelay = 3,
 	kRA2PSXLevel2AimColumns = 5,
 	kRA2PSXLevel2AimRows = 5,
@@ -278,7 +279,9 @@ enum {
 	// A part ends a second after its last trooper falls.
 	kRA2PSXLevel2ClearTicks = 60,
 	// A trooper hides behind its own sprite until its slot has counted down.
-	kRA2PSXLevel2SlotNever = 0x7fffffff
+	kRA2PSXLevel2SlotNever = 0x7fffffff,
+	// How often a held fire button repeats, in ticks.
+	kRA2PSXLevel2FireRepeat = 12
 };
 
 // The states a play script actor walks, named after the values the original stores.
@@ -348,6 +351,8 @@ struct RA2PSXLevel2PartInfo {
 	int16 aimLeft, aimTop, aimRight, aimBottom;
 	int16 aimColumns[kRA2PSXLevel2AimColumns];
 	int16 aimRows[kRA2PSXLevel2AimRows];
+	// Where the aim grid starts; the cover run and the pose count shift with it.
+	int16 aimBase;
 	RA2PSXLevel2Pose poses[kRA2PSXLevel2FrameCount];
 	// How many trooper slots the part scripts, and the sheet sprites whose palettes the
 	// streamed trooper and bolt frames borrow.
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 3ae06f51375..77b54391359 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -89,6 +89,8 @@ private:
 			const Common::Array<uint32> &palette, int left, int top) const;
 	void setScrollTarget(const int16 target[2][2]);
 	int aimFrame(int aimX, int aimY) const;
+	// How many poses the rookie walks leaning out; the aim grid starts right after.
+	int coverFrames() const;
 	// Restarts an actor on the given animation, the way the original's loader does.
 	void startActor(RA2PSXLevel2Actor &actor, int state, int animation);
 	const RA2PSXPlayFrame *actorFrame(const RA2PSXLevel2Actor &actor) const;


Commit: 2a8dc23c1a92386dc5490a7820e91209013b8836
    https://github.com/scummvm/scummvm/commit/2a8dc23c1a92386dc5490a7820e91209013b8836
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: fix L2 shot origin, cover occlusion and cutscene order in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level2.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.h


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
index 9515582b77e..701dc52bf16 100644
--- a/engines/scumm/insane/rebel2/psx/level2.cpp
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -61,7 +61,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
 		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
 		},
-		2, { "TRP_0", "TRP_1", nullptr }, { "LAS_0", "LAS_1", nullptr },
+		2, { "TRP_0", "TRP_1", nullptr }, { "LAS_0", "LAS_1", nullptr }, 1, 0x3e,
 		{ { { 7, 0x1f }, { 7, 0x1a }, { 8, 0x0b } },
 		  { { 7, 0x36 }, { 7, 0x28 }, { 8, 0x1a } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } },
@@ -87,7 +87,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 156, 112, 72, 88 }, { 159, 106, 72, 94 }, { 158, 112, 76, 88 },
 		{ 160, 112, 78, 88 }, { 159, 113, 80, 87 }, { 160, 115, 80, 85 }
 		},
-		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" }, 0, 0x44,
 		{ { { 0x12, 0x2b }, { 0x0e, 0x24 }, { 0x0e, 0x19 } },
 		  { { 0x17, 0x3a }, { 0x15, 0x28 }, { 0x15, 0x1e } },
 		  { { 0x0d, 0x3b }, { 0x0d, 0x29 }, { 0x0f, 0x1c } } } },
@@ -114,7 +114,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 90, 102, 76, 82 }, { 93, 95, 90, 89 }, { 95, 101, 90, 83 },
 		{ 95, 103, 90, 81 }, { 95, 103, 92, 81 }, { 93, 103, 90, 81 }
 		},
-		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" }, 0, 0x44,
 		{ { { 0, 0 }, { 0, 0 }, { 0, 0 } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } }
@@ -147,6 +147,26 @@ const int16 kRA2PSXLevel2BoltTable[kRA2PSXLevel2PartCount][3][2] = {
 
 const int16 kRA2PSXLevel2KillScore[3] = { 80, 100, 150 };
 
+// Where the gun points for each aim pose. The five cover poses never fire.
+const int16 kRA2PSXLevel2Muzzles[2][30][2] = {
+	{
+	{ 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 },
+	{ 49, 86 }, { 49, 86 }, { 62, 100 }, { 65, 132 }, { 65, 132 },
+	{ 72, 80 }, { 80, 90 }, { 63, 100 }, { 65, 120 }, { 58, 125 },
+	{ 93, 81 }, { 93, 90 }, { 101, 99 }, { 89, 109 }, { 89, 129 },
+	{ 108, 82 }, { 108, 90 }, { 111, 100 }, { 116, 115 }, { 116, 130 },
+	{ 119, 83 }, { 119, 90 }, { 124, 100 }, { 127, 112 }, { 127, 130 }
+	},
+	{
+	{ 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 },
+	{ 63, 71 }, { 63, 80 }, { 67, 90 }, { 76, 123 }, { 76, 123 },
+	{ 78, 67 }, { 78, 70 }, { 81, 95 }, { 76, 122 }, { 76, 122 },
+	{ 87, 64 }, { 87, 72 }, { 96, 88 }, { 100, 102 }, { 100, 116 },
+	{ 101, 61 }, { 104, 73 }, { 105, 84 }, { 111, 99 }, { 111, 102 },
+	{ 109, 60 }, { 114, 76 }, { 122, 82 }, { 122, 95 }, { 122, 100 }
+	}
+};
+
 RA2PSXLevel2Scene::RA2PSXLevel2Scene() : _part(0), _difficulty(0), _frame(0), _delay(0),
 		_out(false), _moving(false), _tick(0), _remaining(0), _active(0), _clearTicks(0),
 		_kills(0), _misses(0) {
@@ -350,19 +370,57 @@ int RA2PSXLevel2Scene::updateEnemies(Common::RandomSource &random) {
 		trooper.slotTick = slotDelay(slot[0], slot[1], random);
 	}
 
+	for (int i = 0; i < kRA2PSXLevel2ShotCount; ++i) {
+		if (!_shots[i].step)
+			continue;
+		_shots[i].step += kRA2PSXLevel2ShotStep;
+		if (_shots[i].step > kRA2PSXLevel2ShotEnd)
+			_shots[i].step = 0;
+	}
+
 	if (_remaining <= 0)
 		++_clearTicks;
 	return damage;
 }
 
+void RA2PSXLevel2Scene::projectShot(const RA2PSXLevel2Shot &shot, int step,
+		int &x, int &y) const {
+	step = CLIP<int>(step, 0, 4096);
+	// The bolt is a straight 3D line from the gun at z 1000 to the crosshair at z 18000.
+	// Projecting that line back through its own endpoints cancels the focal length, so
+	// the screen position is a perspective weighted blend of the two screen points.
+	const int nearZ = kRA2PSXLevel2ShotNearZ;
+	const int farZ = kRA2PSXLevel2ShotFarZ;
+	const int z = nearZ + (farZ - nearZ) * step / 4096;
+	const int deltaX = shot.targetX * farZ - shot.muzzleX * nearZ;
+	const int deltaY = shot.targetY * farZ - shot.muzzleY * nearZ;
+	x = (int)((shot.muzzleX * nearZ + (int)(((int64)deltaX * step) >> 12)) / z);
+	y = (int)((shot.muzzleY * nearZ + (int)(((int64)deltaY * step) >> 12)) / z);
+}
+
 int RA2PSXLevel2Scene::shoot(int aimX, int aimY) {
 	if (!outOfCover())
 		return 0;
 
+	const RA2PSXLevel2PartInfo &part = info();
+	// The bolt leaves the gun this pose points, exactly as the DOS build's per sprite
+	// origin table does; the table is biased against the projection's own centre.
+	const int16 *muzzle = kRA2PSXLevel2Muzzles[part.muzzleTable][MIN(_frame, 29)];
+	for (int i = 0; i < kRA2PSXLevel2ShotCount; ++i) {
+		if (_shots[i].step)
+			continue;
+		_shots[i].step = kRA2PSXLevel2ShotStep;
+		_shots[i].muzzleX = muzzle[0] - 4;
+		_shots[i].muzzleY = muzzle[1] + 116 - part.muzzleBias;
+		_shots[i].targetX = aimX;
+		_shots[i].targetY = aimY;
+		break;
+	}
+
 	// The crosshair is in screen space; a frame's box is where the backdrop puts it.
 	const int shotX = aimX + (_scroll[0][0] >> 16);
 	const int shotY = aimY - kRA2PSXLevel2SceneTop + (_scroll[0][1] >> 16);
-	for (int i = 0; i < info().trooperCount; ++i) {
+	for (int i = 0; i < part.trooperCount; ++i) {
 		RA2PSXLevel2Actor &trooper = _troopers[i];
 		if (trooper.state == kRA2PSXLevel2StateIdle ||
 				trooper.state == kRA2PSXLevel2StateCover ||
@@ -543,12 +601,6 @@ void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) con
 			if (frame)
 				drawPlayFrame(surface, *frame, *palette, actorX, actorY);
 		}
-		const Common::Array<uint32> *bolt = _textures.palette(part.boltPalettes[i]);
-		if (bolt && _bolts[i].state != kRA2PSXLevel2StateIdle) {
-			const RA2PSXPlayFrame *frame = actorFrame(_bolts[i]);
-			if (frame)
-				drawPlayFrame(surface, *frame, *bolt, actorX, actorY);
-		}
 	}
 
 	for (int i = 0; i < kRA2PSXLevel2LayerCount; ++i) {
@@ -556,6 +608,20 @@ void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) con
 			drawLayer(surface, part.layers[i], left, top);
 	}
 
+	// A bolt flies past the near wall on its way to the camera, so it sits in front of
+	// it - but only while the rookie is out; cover stops the shot, as the DOS handler
+	// does by refusing to draw a beam at all while he is ducked.
+	if (exposed()) {
+		for (int i = 0; i < part.trooperCount; ++i) {
+			const Common::Array<uint32> *bolt = _textures.palette(part.boltPalettes[i]);
+			if (!bolt || _bolts[i].state == kRA2PSXLevel2StateIdle)
+				continue;
+			const RA2PSXPlayFrame *frame = actorFrame(_bolts[i]);
+			if (frame)
+				drawPlayFrame(surface, *frame, *bolt, actorX, actorY);
+		}
+	}
+
 	// The rookie stands nearest of all, in front of his own cover.
 	if ((uint)_frame < _rookie.size()) {
 		const RA2PSXLevel2Pose &pose = part.poses[_frame];
@@ -563,6 +629,21 @@ void RA2PSXLevel2Scene::draw(Graphics::Surface &surface, int aimX, int aimY) con
 				top + pose.y + part.rookieOffsetY);
 	}
 
+	// The player's own bolts are nearest of all, and stop with him behind cover.
+	static const byte kShotHead[3] = { 0xff, 0xf0, 0xc0 };
+	static const byte kShotTail[3] = { 0xc0, 0x30, 0x00 };
+	for (int i = 0; exposed() && i < kRA2PSXLevel2ShotCount; ++i) {
+		if (!_shots[i].step || _shots[i].step >= kRA2PSXLevel2ShotDraw)
+			continue;
+		int headX, headY, tailX, tailY;
+		projectShot(_shots[i], _shots[i].step, headX, headY);
+		projectShot(_shots[i], _shots[i].step - kRA2PSXLevel2ShotStep, tailX, tailY);
+		for (int thickness = 0; thickness < 2; ++thickness) {
+			drawRA2PSXGouraudLine(surface, left + tailX, top + tailY + thickness,
+					left + headX, top + headY + thickness, kShotTail, kShotHead);
+		}
+	}
+
 	if (outOfCover() && _hud.has("CROSS"))
 		_hud.draw(surface, "CROSS", left + aimX - 4, top + aimY - 3,
 				Common::Rect(0, 0, 8, 7));
@@ -580,8 +661,10 @@ public:
 
 	bool shouldQuit() const override { return _psx._vm->shouldQuit(); }
 
+	// The briefing, then the long cinematic that drops the rookie into the corridor.
 	bool playOpening() override {
-		return _psx.playVideo("S1/L02_INTR.STR", 1, false);
+		return _psx.playVideo("S1/L02_INTR.STR", 1, false) &&
+				_psx.playVideo("S1/L02_CUT1.STR", 1, false);
 	}
 
 	void beginAttempt() override {
@@ -625,9 +708,10 @@ public:
 		return credit;
 	}
 
+	// The two short links between the parts; CUT1 belongs to the opening.
 	bool playPhaseEnd(int phase) override {
 		static const char *const cutscenes[] = {
-			"S1/L02_CUT1.STR", "S1/L02_CUT2.STR"
+			"S1/L02_CUT2.STR", "S1/L02_CUT3.STR"
 		};
 		return _psx.playVideo(cutscenes[phase - 1], 1, false);
 	}
@@ -651,7 +735,6 @@ public:
 	}
 
 	void playComplete(int) override {
-		_psx.playVideo("S1/L02_CUT3.STR", 1, false);
 		_psx.playVideo("S1/L02_EXTR.STR", 1, false);
 	}
 
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index cd0ee202f95..6c02160ff9c 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -281,7 +281,17 @@ enum {
 	// A trooper hides behind its own sprite until its slot has counted down.
 	kRA2PSXLevel2SlotNever = 0x7fffffff,
 	// How often a held fire button repeats, in ticks.
-	kRA2PSXLevel2FireRepeat = 12
+	kRA2PSXLevel2FireRepeat = 12,
+	// A player bolt runs a 3D line from the gun to the crosshair: the parameter steps
+	// by 300 of 4096 a tick, the bolt stops drawing at 4000 and is dropped past 0x112f.
+	// Two slots, the same as the DOS build's cover handler.
+	kRA2PSXLevel2ShotCount = 2,
+	kRA2PSXLevel2ShotStep = 300,
+	kRA2PSXLevel2ShotDraw = 4000,
+	kRA2PSXLevel2ShotEnd = 0x112f,
+	// The bolt leaves the muzzle at z 1000 and reaches the crosshair at z 18000.
+	kRA2PSXLevel2ShotNearZ = 1000,
+	kRA2PSXLevel2ShotFarZ = 18000
 };
 
 // The states a play script actor walks, named after the values the original stores.
@@ -295,6 +305,22 @@ enum {
 	kRA2PSXLevel2StateCover = 0x80
 };
 
+// One of the player's own bolts, travelling from the gun toward the crosshair.
+struct RA2PSXLevel2Shot {
+	RA2PSXLevel2Shot() : step(0), muzzleX(0), muzzleY(0), targetX(0), targetY(0) {}
+
+	// Zero when the slot is free; otherwise how far along the line the bolt is.
+	int step;
+	// Both ends in screen space: the gun this pose fires from, and the crosshair.
+	int muzzleX;
+	int muzzleY;
+	int targetX;
+	int targetY;
+};
+
+// Where the gun sits for each rookie pose, as the original's two tables hold it.
+extern const int16 kRA2PSXLevel2Muzzles[2][30][2];
+
 // One play script actor: a trooper, or the bolt it fires.
 struct RA2PSXLevel2Actor {
 	RA2PSXLevel2Actor() : state(kRA2PSXLevel2StateIdle), animation(-1), frame(0), hold(0),
@@ -359,6 +385,9 @@ struct RA2PSXLevel2PartInfo {
 	int trooperCount;
 	const char *trooperPalettes[kRA2PSXLevel2TrooperCount];
 	const char *boltPalettes[kRA2PSXLevel2TrooperCount];
+	// Which muzzle table the part's poses index, and the bias its bolts start from.
+	byte muzzleTable;
+	int16 muzzleBias;
 	// Empty windows mean the part fires as it changes state instead of on a frame count.
 	RA2PSXLevel2FireWindow fireWindows[kRA2PSXLevel2TrooperCount][3];
 };
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 77b54391359..3800a03e5f1 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -68,7 +68,7 @@ public:
 	void update(int aimX, int aimY);
 	// One 60Hz tick of the trooper slots. Returns the shield damage it cost the player.
 	int updateEnemies(Common::RandomSource &random);
-	// Kills whatever the crosshair covers; returns the score it earned, or zero.
+	// Fires a bolt at the crosshair and kills whatever it covers; returns the score.
 	int shoot(int aimX, int aimY);
 	bool outOfCover() const { return _out && !_moving; }
 	// Anything but fully ducked; the original only shields a rookie who has finished.
@@ -96,6 +96,8 @@ private:
 	const RA2PSXPlayFrame *actorFrame(const RA2PSXLevel2Actor &actor) const;
 	void advanceActor(RA2PSXLevel2Actor &actor);
 	int slotDelay(int base, int range, Common::RandomSource &random) const;
+	// Projects a bolt's 3D line onto the screen at the given point along it.
+	void projectShot(const RA2PSXLevel2Shot &shot, int step, int &x, int &y) const;
 
 	RA2PSXTextureSet _textures;
 	RA2PSXTextureSet _hud;
@@ -115,6 +117,7 @@ private:
 	// The trooper slots, the bolt each one has in the air, and the wave they draw from.
 	RA2PSXLevel2Actor _troopers[kRA2PSXLevel2TrooperCount];
 	RA2PSXLevel2Actor _bolts[kRA2PSXLevel2TrooperCount];
+	RA2PSXLevel2Shot _shots[kRA2PSXLevel2ShotCount];
 	int _tick;
 	int _remaining;
 	int _active;


Commit: 79b6a1e895bbd53201fdc575f572400ae63ff633
    https://github.com/scummvm/scummvm/commit/79b6a1e895bbd53201fdc575f572400ae63ff633
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:13:28+02:00

Commit Message:
SCUMM: RA2: draw the L2 rookie with its slot CLUT and fire from the pistol in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level2.cpp
    engines/scumm/insane/rebel2/psx/model.cpp
    engines/scumm/insane/rebel2/psx/psx.h


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
index 701dc52bf16..f1c00a61336 100644
--- a/engines/scumm/insane/rebel2/psx/level2.cpp
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -61,7 +61,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 182, 73, 80, 108 }, { 181, 73, 78, 109 }, { 181, 73, 78, 109 },
 		{ 182, 73, 78, 108 }, { 183, 73, 80, 108 }, { 183, 73, 80, 108 }
 		},
-		2, { "TRP_0", "TRP_1", nullptr }, { "LAS_0", "LAS_1", nullptr }, 1, 0x3e,
+		2, { "TRP_0", "TRP_1", nullptr }, { "LAS_0", "LAS_1", nullptr },
 		{ { { 7, 0x1f }, { 7, 0x1a }, { 8, 0x0b } },
 		  { { 7, 0x36 }, { 7, 0x28 }, { 8, 0x1a } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } },
@@ -87,7 +87,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 156, 112, 72, 88 }, { 159, 106, 72, 94 }, { 158, 112, 76, 88 },
 		{ 160, 112, 78, 88 }, { 159, 113, 80, 87 }, { 160, 115, 80, 85 }
 		},
-		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" }, 0, 0x44,
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
 		{ { { 0x12, 0x2b }, { 0x0e, 0x24 }, { 0x0e, 0x19 } },
 		  { { 0x17, 0x3a }, { 0x15, 0x28 }, { 0x15, 0x1e } },
 		  { { 0x0d, 0x3b }, { 0x0d, 0x29 }, { 0x0f, 0x1c } } } },
@@ -114,7 +114,7 @@ const RA2PSXLevel2PartInfo kRA2PSXLevel2Parts[kRA2PSXLevel2PartCount] = {
 		{ 90, 102, 76, 82 }, { 93, 95, 90, 89 }, { 95, 101, 90, 83 },
 		{ 95, 103, 90, 81 }, { 95, 103, 92, 81 }, { 93, 103, 90, 81 }
 		},
-		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" }, 0, 0x44,
+		3, { "TRP_0", "TRP_1", "TRP_2" }, { "LAS_0", "LAS_1", "LAS_2" },
 		{ { { 0, 0 }, { 0, 0 }, { 0, 0 } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } },
 		  { { 0, 0 }, { 0, 0 }, { 0, 0 } } } }
@@ -147,26 +147,6 @@ const int16 kRA2PSXLevel2BoltTable[kRA2PSXLevel2PartCount][3][2] = {
 
 const int16 kRA2PSXLevel2KillScore[3] = { 80, 100, 150 };
 
-// Where the gun points for each aim pose. The five cover poses never fire.
-const int16 kRA2PSXLevel2Muzzles[2][30][2] = {
-	{
-	{ 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 },
-	{ 49, 86 }, { 49, 86 }, { 62, 100 }, { 65, 132 }, { 65, 132 },
-	{ 72, 80 }, { 80, 90 }, { 63, 100 }, { 65, 120 }, { 58, 125 },
-	{ 93, 81 }, { 93, 90 }, { 101, 99 }, { 89, 109 }, { 89, 129 },
-	{ 108, 82 }, { 108, 90 }, { 111, 100 }, { 116, 115 }, { 116, 130 },
-	{ 119, 83 }, { 119, 90 }, { 124, 100 }, { 127, 112 }, { 127, 130 }
-	},
-	{
-	{ 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 },
-	{ 63, 71 }, { 63, 80 }, { 67, 90 }, { 76, 123 }, { 76, 123 },
-	{ 78, 67 }, { 78, 70 }, { 81, 95 }, { 76, 122 }, { 76, 122 },
-	{ 87, 64 }, { 87, 72 }, { 96, 88 }, { 100, 102 }, { 100, 116 },
-	{ 101, 61 }, { 104, 73 }, { 105, 84 }, { 111, 99 }, { 111, 102 },
-	{ 109, 60 }, { 114, 76 }, { 122, 82 }, { 122, 95 }, { 122, 100 }
-	}
-};
-
 RA2PSXLevel2Scene::RA2PSXLevel2Scene() : _part(0), _difficulty(0), _frame(0), _delay(0),
 		_out(false), _moving(false), _tick(0), _remaining(0), _active(0), _clearTicks(0),
 		_kills(0), _misses(0) {
@@ -198,7 +178,10 @@ bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part, int difficu
 			return false;
 	}
 
-	// Every rookie pose is its own member: a format word, a palette and one image.
+	// Every rookie pose is its own member: a format word, a palette and one image. The
+	// poses stream into ROOKIE_A's VRAM slot, so they take that texture's CLUT - their
+	// own often leaves the backdrop index opaque.
+	const Common::Array<uint32> *rookieClut = _textures.palette("ROOKIE_A");
 	_rookie.clear();
 	const int poseCount = kRA2PSXLevel2Parts[part].aimBase + 30;
 	for (int frame = 0; frame < poseCount; ++frame) {
@@ -209,7 +192,8 @@ bool RA2PSXLevel2Scene::load(const RA2PSXArchive &archive, int part, int difficu
 		if (!archive.getMember(path, anim) || anim.size() < 8)
 			return false;
 		const uint16 height = READ_LE_UINT16(anim.data() + 2) & 0xff;
-		if (!loadRA2PSXSpriteAnimation(anim, height ? height : 256, decoded) || decoded.empty())
+		if (!loadRA2PSXSpriteAnimation(anim, height ? height : 256, decoded, rookieClut) ||
+				decoded.empty())
 			return false;
 		_rookie.push_back(decoded[0]);
 	}
@@ -403,15 +387,15 @@ int RA2PSXLevel2Scene::shoot(int aimX, int aimY) {
 		return 0;
 
 	const RA2PSXLevel2PartInfo &part = info();
-	// The bolt leaves the gun this pose points, exactly as the DOS build's per sprite
-	// origin table does; the table is biased against the projection's own centre.
-	const int16 *muzzle = kRA2PSXLevel2Muzzles[part.muzzleTable][MIN(_frame, 29)];
+	// The bolt leaves the pistol the rookie is holding, which the pose box locates: he
+	// aims across the frame, so the gun sits at the leading edge of his sprite.
+	const RA2PSXLevel2Pose &pose = part.poses[CLIP<int>(_frame, 0, kRA2PSXLevel2FrameCount - 1)];
 	for (int i = 0; i < kRA2PSXLevel2ShotCount; ++i) {
 		if (_shots[i].step)
 			continue;
 		_shots[i].step = kRA2PSXLevel2ShotStep;
-		_shots[i].muzzleX = muzzle[0] - 4;
-		_shots[i].muzzleY = muzzle[1] + 116 - part.muzzleBias;
+		_shots[i].muzzleX = pose.x + part.rookieOffsetX + pose.width / 8;
+		_shots[i].muzzleY = pose.y + part.rookieOffsetY + pose.height / 6;
 		_shots[i].targetX = aimX;
 		_shots[i].targetY = aimY;
 		break;
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index fe5aac18b15..bde1ba86ecc 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -189,7 +189,7 @@ bool loadRA2PSXTextures(const Common::Array<byte> &data,
 }
 
 bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
-		Common::Array<RA2PSXTexture> &frames) {
+		Common::Array<RA2PSXTexture> &frames, const Common::Array<uint32> *clut) {
 	if (data.size() < 4 + 512 || !frameHeight)
 		return false;
 	const uint16 widthField = READ_LE_UINT16(data.data());
@@ -212,15 +212,14 @@ bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeig
 		texture.pixels.resize(frameBytes);
 		for (uint32 i = 0; i < frameBytes; ++i) {
 			const byte paletteIndex = data[pixelsOffset + frame * frameBytes + i];
-			const uint16 value = READ_LE_UINT16(data.data() + paletteOffset + paletteIndex * 2);
-			if (!value) {
-				texture.pixels[i] = 0;
+			// Frames streamed into another texture's VRAM slot are drawn through that
+			// slot's CLUT, not the one the member happens to carry.
+			if (clut) {
+				texture.pixels[i] = paletteIndex < clut->size() ? (*clut)[paletteIndex] : 0;
 				continue;
 			}
-			const uint32 r = ((value & 0x1f) << 3) | ((value & 0x1f) >> 2);
-			const uint32 g = (((value >> 5) & 0x1f) << 3) | (((value >> 5) & 0x1f) >> 2);
-			const uint32 b = (((value >> 10) & 0x1f) << 3) | (((value >> 10) & 0x1f) >> 2);
-			texture.pixels[i] = 0x01000000 | (r << 16) | (g << 8) | b;
+			texture.pixels[i] = decodeRA2PSXColor(
+					READ_LE_UINT16(data.data() + paletteOffset + paletteIndex * 2));
 		}
 		frames.push_back(texture);
 	}
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 6c02160ff9c..99b292697ba 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -249,7 +249,7 @@ enum { kRA2PSXExplosionHeight = 56 };
 
 // bigEx: a 4 byte format word, a 256 colour palette and equal sized 8 bit frames.
 bool loadRA2PSXSpriteAnimation(const Common::Array<byte> &data, uint16 frameHeight,
-		Common::Array<RA2PSXTexture> &frames);
+		Common::Array<RA2PSXTexture> &frames, const Common::Array<uint32> *clut = nullptr);
 
 // Level 2's three parts, each a sheet of backdrop halves, parallax walls and actors.
 enum {
@@ -318,9 +318,6 @@ struct RA2PSXLevel2Shot {
 	int targetY;
 };
 
-// Where the gun sits for each rookie pose, as the original's two tables hold it.
-extern const int16 kRA2PSXLevel2Muzzles[2][30][2];
-
 // One play script actor: a trooper, or the bolt it fires.
 struct RA2PSXLevel2Actor {
 	RA2PSXLevel2Actor() : state(kRA2PSXLevel2StateIdle), animation(-1), frame(0), hold(0),
@@ -385,9 +382,6 @@ struct RA2PSXLevel2PartInfo {
 	int trooperCount;
 	const char *trooperPalettes[kRA2PSXLevel2TrooperCount];
 	const char *boltPalettes[kRA2PSXLevel2TrooperCount];
-	// Which muzzle table the part's poses index, and the bias its bolts start from.
-	byte muzzleTable;
-	int16 muzzleBias;
 	// Empty windows mean the part fires as it changes state instead of on a frame count.
 	RA2PSXLevel2FireWindow fireWindows[kRA2PSXLevel2TrooperCount][3];
 };


Commit: 64d051dc08be08b230344d4d55de734304c9d5d4
    https://github.com/scummvm/scummvm/commit/64d051dc08be08b230344d4d55de734304c9d5d4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-26T13:16:23+02:00

Commit Message:
SCUMM: RA2: fixed variable shadow warning in psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/level2.cpp


diff --git a/engines/scumm/insane/rebel2/psx/level2.cpp b/engines/scumm/insane/rebel2/psx/level2.cpp
index f1c00a61336..afb8738b5f3 100644
--- a/engines/scumm/insane/rebel2/psx/level2.cpp
+++ b/engines/scumm/insane/rebel2/psx/level2.cpp
@@ -792,12 +792,12 @@ private:
 					}
 				} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START ||
 						event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END) {
-					const bool pressed = event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START;
+					const bool started = event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START;
 					if (event.customType == kScummActionInsaneAttack) {
-						if (pressed && !firePressed)
+						if (started && !firePressed)
 							fireEdge = true;
-						firePressed = pressed;
-					} else if (pressed && event.customType == kScummActionInsaneSwitch) {
+						firePressed = started;
+					} else if (started && event.customType == kScummActionInsaneSwitch) {
 						_scene.toggleCover();
 					}
 				} else if (event.type == Common::EVENT_MOUSEMOVE) {




More information about the Scummvm-git-logs mailing list