[Scummvm-git-logs] scummvm master -> 1fb9617cfa5a5df05f416d1fee213612ba194710

neuromancer noreply at scummvm.org
Fri Jul 24 11:32:55 UTC 2026


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

Summary:
2652f526db SCUMM: RA2: load cockpit for level 1 in psx
fc0f6f32cc SCUMM: RA2: basic gamepad controls and laser rendering in psx
0d7833eaaa SCUMM: RA2: third person ship view in psx
ce8263d11f SCUMM: RA2: load textures in psx
f1ee431e61 SCUMM: RA2: render all the triangles for the psx release
8875e80135 SCUMM: RA2: improved sound support for psx release
4b8f11a32a SCUMM: RA2: added initial support for main meny in the psx release
a21f477759 PLAYGROUND3D: Added a quad strip test
c81277189f TINYGL: Fix deferred quad strip rendering
91bd24ea24 SCUMM: RA2: font rendering support for psx release
1fb9617cfa SCUMM: RA2: re-use common L1 code for psx release


Commit: 2652f526dbe17c63c7ca851b533d2d0b74094c4c
    https://github.com/scummvm/scummvm/commit/2652f526dbe17c63c7ca851b533d2d0b74094c4c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: load cockpit for level 1 in psx

Changed paths:
  A engines/scumm/insane/rebel2/psx/ui.cpp
  A engines/scumm/insane/rebel2/psx/ui.h
    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/resource.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index ceae659a063..035dba8a892 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -28,6 +28,7 @@
 
 #include "scumm/scumm_v7.h"
 #include "scumm/insane/rebel2/psx/psx.h"
+#include "scumm/insane/rebel2/psx/ui.h"
 #include "scumm/insane/rebel2/psx/video.h"
 
 #include <math.h>
@@ -109,14 +110,11 @@ static void spawnLevel1Enemy(RA2PSXLevel1Enemy &enemy, Common::RandomSource &ran
 	updateLevel1Enemy(enemy);
 }
 
-static void drawLevel1Overlay(Graphics::Surface &surface, const RA2PSXLevel1Enemy *enemies,
-		const RA2PSXLevel1Explosion *explosions, int aimX, int aimY, int shield, int kills,
-		int misses, int frame, int fireFrames, int hitFrames, int damageFrames) {
-	const uint32 white = surface.format.RGBToColor(255, 255, 255);
+static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
+		const RA2PSXLevel1Enemy *enemies, const RA2PSXLevel1Explosion *explosions,
+		int aimX, int aimY, int fireFrames) {
 	const uint32 red = surface.format.RGBToColor(255, 48, 32);
 	const uint32 green = surface.format.RGBToColor(64, 255, 96);
-	const uint32 orange = surface.format.RGBToColor(255, 176, 32);
-	const uint32 dark = surface.format.RGBToColor(20, 32, 28);
 
 	for (int i = 0; i < 3; ++i) {
 		if (enemies[i].active && enemies[i].laserFrames > 0) {
@@ -125,57 +123,26 @@ static void drawLevel1Overlay(Graphics::Surface &surface, const RA2PSXLevel1Enem
 			surface.drawLine((int)enemies[i].x + 2, (int)enemies[i].y,
 					175, surface.h - 1, green);
 		}
-		if (explosions[i].frames > 0) {
-			const int radius = 2 + (8 - explosions[i].frames) * 2;
-			surface.drawLine(explosions[i].x - radius, explosions[i].y,
-					explosions[i].x + radius, explosions[i].y, orange);
-			surface.drawLine(explosions[i].x, explosions[i].y - radius,
-					explosions[i].x, explosions[i].y + radius, orange);
-			surface.drawLine(explosions[i].x - radius / 2, explosions[i].y - radius / 2,
-					explosions[i].x + radius / 2, explosions[i].y + radius / 2, red);
-			surface.drawLine(explosions[i].x + radius / 2, explosions[i].y - radius / 2,
-					explosions[i].x - radius / 2, explosions[i].y + radius / 2, red);
-		}
+		if (explosions[i].frames > 0)
+			ui.drawExplosion(surface, explosions[i].x, explosions[i].y,
+					10 - explosions[i].frames);
 	}
 
 	if (fireFrames > 0) {
 		surface.drawLine(38, surface.h - 1, aimX - 2, aimY, red);
 		surface.drawLine(surface.w - 39, surface.h - 1, aimX + 2, aimY, red);
 	}
-
-	const uint32 crosshair = hitFrames > 0 ? red : white;
-	surface.drawLine(aimX - 12, aimY, aimX - 4, aimY, crosshair);
-	surface.drawLine(aimX + 4, aimY, aimX + 12, aimY, crosshair);
-	surface.drawLine(aimX, aimY - 12, aimX, aimY - 4, crosshair);
-	surface.drawLine(aimX, aimY + 4, aimX, aimY + 12, crosshair);
-	surface.frameRect(Common::Rect(aimX - 8, aimY - 8, aimX + 9, aimY + 9), crosshair);
-
-	surface.fillRect(Common::Rect(8, surface.h - 14, 112, surface.h - 4), dark);
-	surface.frameRect(Common::Rect(8, surface.h - 14, 112, surface.h - 4), white);
-	const uint32 shieldColor = shield > 50 ? green : (shield > 20 ? orange : red);
-	if (shield > 0)
-		surface.fillRect(Common::Rect(10, surface.h - 12, 10 + shield, surface.h - 6), shieldColor);
-
-	for (int i = 0; i < MIN(kills, 20); ++i)
-		surface.fillRect(Common::Rect(8 + (i % 10) * 4, 6 + (i / 10) * 5,
-				10 + (i % 10) * 4, 9 + (i / 10) * 5), green);
-	for (int i = 0; i < MIN(misses, 20); ++i)
-		surface.fillRect(Common::Rect(surface.w - 10 - (i % 10) * 4, 6 + (i / 10) * 5,
-				surface.w - 8 - (i % 10) * 4, 9 + (i / 10) * 5), red);
-
-	const int progress = frame * (surface.w - 2) / 1699;
-	if (progress > 0)
-		surface.drawLine(1, surface.h - 2, progress, surface.h - 2, white);
-	if (damageFrames > 0) {
-		surface.frameRect(Common::Rect(1, 1, surface.w - 1, surface.h - 1), red);
-		surface.frameRect(Common::Rect(2, 2, surface.w - 2, surface.h - 2), red);
-	}
 }
 #endif
 
-Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
+Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
+		const RA2PSXModel &crosshair, const RA2PSXLevel1UI &ui, int lives, int &score) {
 #ifndef USE_TINYGL
 	(void)model;
+	(void)crosshair;
+	(void)ui;
+	(void)lives;
+	(void)score;
 	return kLevel1Error;
 #else
 	Common::SeekableReadStream *stream = openRawFile("S1/L01_PLAY.STR", 1);
@@ -196,11 +163,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 
 	RA2PSXLevel1Enemy enemies[3];
 	RA2PSXLevel1Explosion explosions[3];
-	int aimX = _vm->_screenWidth / 2;
-	int aimY = _vm->_screenHeight / 2;
+	int aimX = 160;
+	int aimY = 113;
 	int shield = 100;
-	int kills = 0;
-	int misses = 0;
 	int spawnDelay = 0;
 	int spawnRange = 80;
 	int spawnBase = 60;
@@ -208,8 +173,6 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 	int lastFrame = -1;
 	int lastShotFrame = -10;
 	int fireFrames = 0;
-	int hitFrames = 0;
-	int damageFrames = 0;
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
@@ -229,12 +192,12 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 		while (g_system->getEventManager()->pollEvent(event)) {
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
-				aimX = CLIP<int>(event.mouse.x, 14, _vm->_screenWidth - 15);
-				aimY = CLIP<int>(event.mouse.y, 14, _vm->_screenHeight - 18);
+				aimX = CLIP<int>(event.mouse.x, 30, 290);
+				aimY = CLIP<int>(event.mouse.y, 48, 178);
 				break;
 			case Common::EVENT_LBUTTONDOWN:
-				aimX = CLIP<int>(event.mouse.x, 14, _vm->_screenWidth - 15);
-				aimY = CLIP<int>(event.mouse.y, 14, _vm->_screenHeight - 18);
+				aimX = CLIP<int>(event.mouse.x, 30, 290);
+				aimY = CLIP<int>(event.mouse.y, 48, 178);
 				mouseFire = true;
 				fireRequested = true;
 				break;
@@ -298,9 +261,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 			while (lastFrame < currentFrame && shield > 0) {
 				++lastFrame;
 				aimX = CLIP<int>(aimX + ((int)moveRight - (int)moveLeft) * 6,
-						14, _vm->_screenWidth - 15);
+						30, 290);
 				aimY = CLIP<int>(aimY + ((int)moveDown - (int)moveUp) * 6,
-						14, _vm->_screenHeight - 18);
+						48, 178);
 
 				if (lastFrame >= nextSpawnAdjustment) {
 					nextSpawnAdjustment = lastFrame + 20;
@@ -324,10 +287,6 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 
 				if (fireFrames > 0)
 					--fireFrames;
-				if (hitFrames > 0)
-					--hitFrames;
-				if (damageFrames > 0)
-					--damageFrames;
 				for (int i = 0; i < 3; ++i) {
 					if (explosions[i].frames > 0)
 						--explosions[i].frames;
@@ -340,18 +299,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 					updateLevel1Enemy(enemies[i]);
 					if (enemies[i].age == enemies[i].fireFrame) {
 						enemies[i].laserFrames = 4;
-						if (_vm->_rnd.getRandomNumber(99) < 38) {
+						if (_vm->_rnd.getRandomNumber(99) < 38)
 							shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
-							damageFrames = 4;
-						}
 					}
 					if (enemies[i].age >= enemies[i].lifetime) {
 						enemies[i].active = false;
-						++misses;
-						if (_vm->_rnd.getRandomNumber(99) < 18) {
+						if (_vm->_rnd.getRandomNumber(99) < 18)
 							shield = MAX(0, shield - 12);
-							damageFrames = 4;
-						}
 					}
 				}
 			}
@@ -383,11 +337,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 				}
 				if (hitEnemy >= 0) {
 					enemies[hitEnemy].active = false;
-					++kills;
-					hitFrames = 3;
+					score = MIN(9999999, score + 100);
 					for (int i = 0; i < 3; ++i) {
 						if (explosions[i].frames == 0) {
-							explosions[i].frames = 8;
+							explosions[i].frames = 10;
 							explosions[i].x = (int)enemies[hitEnemy].x;
 							explosions[i].y = (int)enemies[hitEnemy].y;
 							break;
@@ -402,10 +355,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model) {
 					renderer.renderModel(model, enemies[i].x, enemies[i].y, enemies[i].size,
 							enemies[i].pitch, enemies[i].yaw, enemies[i].roll);
 			}
+			renderer.renderModel(crosshair, aimX, aimY, 31.0f,
+					(aimY - 113) * 0.12f, -(aimX - 160) * 0.10f, 0.0f, false);
 			Graphics::Surface output;
 			renderer.finishFrame(output);
-			drawLevel1Overlay(output, enemies, explosions, aimX, aimY, shield, kills, misses,
-					currentFrame, fireFrames, hitFrames, damageFrames);
+			drawLevel1Effects(output, ui, enemies, explosions, aimX, aimY, fireFrames);
+			ui.drawCockpit(output);
+			ui.drawHUD(output, score, lives, shield, currentFrame);
 			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
 			g_system->updateScreen();
 		}
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index f03027fab8b..aadde528877 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -208,10 +208,12 @@ void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background) {
 }
 
 void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float y, float size,
-		float pitch, float yaw, float roll) {
+		float pitch, float yaw, float roll, bool depthTest) {
 	if (!_context || model.vertices().empty())
 		return;
 	TinyGL::setContext(_context);
+	if (!depthTest)
+		tglDisable(TGL_DEPTH_TEST);
 	tglMatrixMode(TGL_MODELVIEW);
 	tglPushMatrix();
 	tglTranslatef(x, y, 0.0f);
@@ -234,6 +236,8 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 		tglEnd();
 	}
 	tglPopMatrix();
+	if (!depthTest)
+		tglEnable(TGL_DEPTH_TEST);
 }
 
 void RA2PSXTinyGLRenderer::finishFrame(Graphics::Surface &surface) {
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index cd39c3552de..d78be61c0df 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -28,6 +28,7 @@
 
 #include "scumm/scumm_v7.h"
 #include "scumm/insane/rebel2/psx/psx.h"
+#include "scumm/insane/rebel2/psx/ui.h"
 #include "scumm/insane/rebel2/psx/video.h"
 
 namespace Scumm {
@@ -113,7 +114,8 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 	return !_vm->shouldQuit();
 }
 
-bool Rebel2PSX::loadLevel1Model(RA2PSXModel &model) {
+bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
+		RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -124,13 +126,18 @@ bool Rebel2PSX::loadLevel1Model(RA2PSXModel &model) {
 		return false;
 
 	Common::Array<byte> modelData;
-	return archive.getMember("fOFS/TieFighter/main", modelData) && model.load(modelData);
+	Common::Array<byte> crosshairData;
+	return archive.getMember("fOFS/TieFighter/main", modelData) && model.load(modelData) &&
+			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
+			ui.load(archive);
 }
 
 Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
 	RA2PSXModel model;
-	if (!loadLevel1Model(model))
+	RA2PSXModel crosshair;
+	RA2PSXLevel1UI ui;
+	if (!loadLevel1Assets(model, crosshair, ui))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
@@ -142,8 +149,9 @@ Common::Error Rebel2PSX::runGame() {
 	}
 
 	int lives = 3;
+	int score = 0;
 	while (!_vm->shouldQuit()) {
-		const Level1Result result = playLevel1(model);
+		const Level1Result result = playLevel1(model, crosshair, ui, lives, score);
 		if (result == kLevel1Quit)
 			return Common::kNoError;
 		if (result == kLevel1Error)
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 5667d84abd9..38aca961b99 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -32,6 +32,7 @@ class SeekableReadStream;
 namespace Scumm {
 
 class ScummEngine_v7;
+class RA2PSXLevel1UI;
 
 class RA2PSXArchive {
 public:
@@ -97,7 +98,7 @@ public:
 	bool init(int width, int height);
 	void beginFrame(const Graphics::Surface &background);
 	void renderModel(const RA2PSXModel &model, float x, float y, float size,
-			float pitch, float yaw, float roll);
+			float pitch, float yaw, float roll, bool depthTest = true);
 	void finishFrame(Graphics::Surface &surface);
 
 private:
@@ -125,8 +126,9 @@ private:
 	Common::SeekableReadStream *openResource(int number);
 	Common::SeekableReadStream *openRawFile(const Common::Path &path, int discNumber);
 	bool playVideo(const Common::Path &path, int discNumber, bool version2);
-	bool loadLevel1Model(RA2PSXModel &model);
-	Level1Result playLevel1(const RA2PSXModel &model);
+	bool loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair, RA2PSXLevel1UI &ui);
+	Level1Result playLevel1(const RA2PSXModel &model, const RA2PSXModel &crosshair,
+			const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
 };
diff --git a/engines/scumm/insane/rebel2/psx/resource.cpp b/engines/scumm/insane/rebel2/psx/resource.cpp
index afa1f1bf443..4c397da44ba 100644
--- a/engines/scumm/insane/rebel2/psx/resource.cpp
+++ b/engines/scumm/insane/rebel2/psx/resource.cpp
@@ -132,8 +132,8 @@ bool RA2PSXArchive::findNestedMember(const Common::Array<byte> &container,
 			while (!entryName.empty() && entryName.lastChar() == 0)
 				entryName.deleteLastChar();
 			const uint32 relativeOffset = READ_LE_UINT32(container.data() + record + 12);
-			const uint32 absoluteOffset = directory + relativeOffset;
-			if (absoluteOffset < directory || absoluteOffset > boundary)
+			const uint32 absoluteOffset = record + relativeOffset;
+			if (absoluteOffset < record || absoluteOffset > boundary)
 				return false;
 			if (entryName.equalsIgnoreCase(component)) {
 				found = true;
@@ -146,7 +146,7 @@ bool RA2PSXArchive::findNestedMember(const Common::Array<byte> &container,
 		for (uint32 record = directory; record + 16 <= boundary; record += 16) {
 			if (container[record] == 0)
 				break;
-			const uint32 absoluteOffset = directory + READ_LE_UINT32(container.data() + record + 12);
+			const uint32 absoluteOffset = record + READ_LE_UINT32(container.data() + record + 12);
 			if (absoluteOffset > selectedOffset && absoluteOffset < selectedEnd)
 				selectedEnd = absoluteOffset;
 		}
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
new file mode 100644
index 00000000000..a34df27bfc9
--- /dev/null
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -0,0 +1,283 @@
+/* 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.
+ */
+
+#include "common/endian.h"
+#include "common/util.h"
+
+#include "scumm/insane/rebel2/psx/psx.h"
+#include "scumm/insane/rebel2/psx/ui.h"
+
+namespace Scumm {
+
+struct RA2PSXUIColor {
+	int r;
+	int g;
+	int b;
+};
+
+struct RA2PSXUIGradientStop {
+	int index;
+	RA2PSXUIColor color;
+};
+
+static RA2PSXUIColor interpolateColor(const RA2PSXUIColor &from,
+		const RA2PSXUIColor &to, int value, int maximum) {
+	if (maximum <= 0)
+		return from;
+	RA2PSXUIColor color;
+	color.r = from.r + (to.r - from.r) * value / maximum;
+	color.g = from.g + (to.g - from.g) * value / maximum;
+	color.b = from.b + (to.b - from.b) * value / maximum;
+	return color;
+}
+
+static 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,
+					index - stops[i - 1].index, stops[i].index - stops[i - 1].index);
+	}
+	return stops[count - 1].color;
+}
+
+bool RA2PSXLevel1UI::loadTextures(const Common::Array<byte> &data) {
+	const uint initialCount = _textures.size();
+	uint32 offset = 0;
+	while (offset + 20 <= data.size()) {
+		Common::String name;
+		for (uint i = 0; i < 8 && data[offset + i]; ++i) {
+			if (data[offset + i] < 0x20 || data[offset + i] >= 0x7f)
+				return _textures.size() > initialCount;
+			name += (char)data[offset + i];
+		}
+		if (name.empty())
+			break;
+
+		const uint16 recordSize = READ_LE_UINT16(data.data() + offset + 12);
+		const uint16 widthField = READ_LE_UINT16(data.data() + offset + 16);
+		const uint16 heightField = READ_LE_UINT16(data.data() + offset + 18);
+		const uint16 width = (widthField & 0xff) ? widthField & 0xff : 256;
+		const uint16 height = (heightField & 0xff) ? heightField & 0xff : 256;
+		const bool eightBit = (widthField & 0x100) != 0;
+		const uint32 paletteColors = eightBit ? 256 : 16;
+		const uint32 pixelCount = (uint32)width * height;
+		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)
+			break;
+
+		Texture texture;
+		texture.name = name;
+		texture.width = width;
+		texture.height = height;
+		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 | (r << 16) | (g << 8) | b;
+		}
+		_textures.push_back(texture);
+		offset += recordSize;
+	}
+	return _textures.size() > initialCount;
+}
+
+const RA2PSXLevel1UI::Texture *RA2PSXLevel1UI::findTexture(const char *name) const {
+	for (uint i = 0; i < _textures.size(); ++i) {
+		if (_textures[i].name.equalsIgnoreCase(name))
+			return &_textures[i];
+	}
+	return nullptr;
+}
+
+bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
+	Common::Array<byte> data;
+	_textures.clear();
+	if (!archive.getMember("tex/Common", data) || !loadTextures(data))
+		return false;
+	if (!archive.getMember("tex/BWingCockp", data) || !loadTextures(data))
+		return false;
+
+	static const char *const required[] = {
+		"COCKPITL", "COCKPITR", "PANEL1", "PANEL2", "PANEL3", "STATTEXT",
+		"FONT8X9", "REBLSIGN", "ENRGYMSK", "SMALLEX"
+	};
+	for (uint i = 0; i < ARRAYSIZE(required); ++i) {
+		if (!findTexture(required[i]))
+			return false;
+	}
+	return true;
+}
+
+void RA2PSXLevel1UI::drawTexture(Graphics::Surface &surface, const char *name,
+		int x, int y, const Common::Rect &source, int brightness, BlendMode blend) const {
+	const Texture *texture = findTexture(name);
+	if (!texture)
+		return;
+
+	int sourceLeft = MAX<int>(0, source.left);
+	int sourceTop = MAX<int>(0, source.top);
+	int sourceRight = MIN<int>(texture->width, source.right);
+	int sourceBottom = MIN<int>(texture->height, source.bottom);
+	x += sourceLeft - source.left;
+	y += sourceTop - source.top;
+	if (x < 0) {
+		sourceLeft -= x;
+		x = 0;
+	}
+	if (y < 0) {
+		sourceTop -= y;
+		y = 0;
+	}
+	sourceRight = MIN<int>(sourceRight, sourceLeft + surface.w - x);
+	sourceBottom = MIN<int>(sourceBottom, sourceTop + surface.h - y);
+	if (sourceLeft >= sourceRight || sourceTop >= sourceBottom)
+		return;
+
+	brightness = CLIP<int>(brightness, 0, 0xff);
+	for (int sourceY = sourceTop; sourceY < sourceBottom; ++sourceY) {
+		const int destY = y + sourceY - sourceTop;
+		for (int sourceX = sourceLeft; sourceX < sourceRight; ++sourceX) {
+			const uint32 pixel = texture->pixels[sourceY * texture->width + sourceX];
+			if (!(pixel & 0x01000000))
+				continue;
+
+			int r = ((pixel >> 16) & 0xff) * brightness / 0x80;
+			int g = ((pixel >> 8) & 0xff) * brightness / 0x80;
+			int b = (pixel & 0xff) * brightness / 0x80;
+			r = MIN(r, 0xff);
+			g = MIN(g, 0xff);
+			b = MIN(b, 0xff);
+			const int destX = x + sourceX - sourceLeft;
+			if (blend == kBlendAdditive) {
+				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);
+			}
+			surface.setPixel(destX, destY, surface.format.RGBToColor(r, g, b));
+		}
+	}
+}
+
+void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface) const {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2 + 120;
+	drawTexture(surface, "COCKPITL", xOffset, yOffset, Common::Rect(0, 0, 224, 120));
+	drawTexture(surface, "COCKPITR", xOffset + 224, yOffset, Common::Rect(0, 0, 120, 120));
+}
+
+void RA2PSXLevel1UI::drawExplosion(Graphics::Surface &surface, int x, int y, int frame) const {
+	const int stage = CLIP<int>(frame / 2, 0, 4);
+	drawTexture(surface, "SMALLEX", x - 8, y - 8,
+			Common::Rect(stage * 16, 0, stage * 16 + 16, 16), 0x80, kBlendAdditive);
+}
+
+void RA2PSXLevel1UI::drawShield(Graphics::Surface &surface, int shield,
+		int xOffset, int yOffset) const {
+	static const RA2PSXUIGradientStop topLeft[] = {
+		{ 0, { 0, 83, 0 } }, { 14, { 171, 147, 0 } }, { 31, { 63, 0, 0 } }
+	};
+	static const RA2PSXUIGradientStop topRight[] = {
+		{ 0, { 51, 151, 0 } }, { 6, { 207, 183, 0 } },
+		{ 16, { 202, 0, 0 } }, { 31, { 155, 0, 0 } }
+	};
+	static const RA2PSXUIGradientStop bottomLeft[] = {
+		{ 0, { 0, 159, 0 } }, { 13, { 246, 214, 0 } }, { 31, { 183, 0, 0 } }
+	};
+	static const RA2PSXUIGradientStop bottomRight[] = {
+		{ 0, { 226, 207, 0 } }, { 15, { 219, 24, 0 } }, { 31, { 139, 3, 0 } }
+	};
+
+	shield = CLIP<int>(shield, 0, 100);
+	const int width = shield * 68 / 100;
+	if (!width)
+		return;
+	const int colorIndex = CLIP<int>(32 - shield * 32 / 100, 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);
+	const RA2PSXUIColor br = shieldColor(bottomRight, ARRAYSIZE(bottomRight), colorIndex);
+
+	for (int row = 0; row < 9; ++row) {
+		const int left = xOffset + 297 - width - row;
+		const int right = xOffset + 288;
+		const int y = yOffset + 213 + row;
+		if (left >= right || y < 0 || y >= surface.h)
+			continue;
+		const RA2PSXUIColor leftColor = interpolateColor(tl, bl, row, 8);
+		const RA2PSXUIColor rightColor = interpolateColor(tr, br, row, 8);
+		for (int x = MAX(0, left); x < MIN<int>(right, surface.w); ++x) {
+			const RA2PSXUIColor color = interpolateColor(leftColor, rightColor,
+					x - left, right - left - 1);
+			surface.setPixel(x, y, surface.format.RGBToColor(color.r, color.g, color.b));
+		}
+
+		const int sourceLeft = 77 - width - row;
+		if (sourceLeft < 68)
+			drawTexture(surface, "ENRGYMSK", left, y,
+					Common::Rect(MAX(0, sourceLeft), row, 68, row + 1), 0x50, kBlendAdditive);
+	}
+}
+
+void RA2PSXLevel1UI::drawHUD(Graphics::Surface &surface, int score, int lives,
+		int shield, int frame) const {
+	const int xOffset = (surface.w - 320) / 2;
+	const int yOffset = (surface.h - 240) / 2;
+
+	drawTexture(surface, "PANEL3", xOffset + 2, yOffset + 17, Common::Rect(0, 0, 126, 21));
+	drawTexture(surface, "PANEL2", xOffset + 262, yOffset + 17, Common::Rect(0, 0, 56, 29));
+	drawTexture(surface, "PANEL1", xOffset + 210, yOffset + 207, Common::Rect(0, 0, 108, 21));
+	drawShield(surface, shield, xOffset, yOffset);
+
+	drawTexture(surface, "STATTEXT", xOffset + 14, yOffset + 21,
+			Common::Rect(42, 0, 62, 13), 0x5a);
+	score = CLIP<int>(score, 0, 9999999);
+	int divisor = 1000000;
+	for (int digit = 0; digit < 7; ++digit) {
+		const int value = score / divisor % 10;
+		drawTexture(surface, "FONT8X9", xOffset + 39 + digit * 10, yOffset + 23,
+				Common::Rect(value * 8, 0, value * 8 + 8, 9));
+		divisor /= 10;
+	}
+
+	const int rebelFrame = (MAX(frame, 0) / 2) % 12;
+	drawTexture(surface, "REBLSIGN", xOffset + 292, yOffset + 22,
+			Common::Rect((rebelFrame % 6) * 20, (rebelFrame / 6) * 19,
+					(rebelFrame % 6 + 1) * 20, (rebelFrame / 6 + 1) * 19));
+	const int reserveLives = CLIP<int>(lives - 1, 0, 9);
+	drawTexture(surface, "FONT8X9", xOffset + 274, yOffset + 28,
+			Common::Rect(reserveLives * 8, 0, reserveLives * 8 + 8, 9));
+	drawTexture(surface, "FONT8X9", xOffset + 283, yOffset + 29, Common::Rect(80, 0, 88, 9));
+
+	int shieldLabelBrightness = 0x5a;
+	if (shield <= 31) {
+		const int phase = (MAX(frame, 0) / 2) % 14;
+		shieldLabelBrightness += phase < 7 ? -50 + phase * 10 : 20 - (phase - 7) * 10;
+	}
+	drawTexture(surface, "STATTEXT", xOffset + 291, yOffset + 211,
+			Common::Rect(0, 0, 20, 13), shieldLabelBrightness);
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
new file mode 100644
index 00000000000..1adbe99831f
--- /dev/null
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -0,0 +1,60 @@
+/* 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.
+ */
+
+#ifndef SCUMM_INSANE_REBEL2_PSX_UI_H
+#define SCUMM_INSANE_REBEL2_PSX_UI_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+#include "common/types.h"
+
+#include "graphics/surface.h"
+
+namespace Scumm {
+
+class RA2PSXArchive;
+
+class RA2PSXLevel1UI {
+public:
+	bool load(const RA2PSXArchive &archive);
+
+	void drawCockpit(Graphics::Surface &surface) 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;
+
+private:
+	enum BlendMode {
+		kBlendOpaque,
+		kBlendAdditive
+	};
+
+	struct Texture {
+		Common::String name;
+		uint16 width;
+		uint16 height;
+		Common::Array<uint32> pixels;
+	};
+
+	bool loadTextures(const Common::Array<byte> &data);
+	const Texture *findTexture(const char *name) const;
+	void drawTexture(Graphics::Surface &surface, const char *name,
+			int x, int y, const Common::Rect &source, int brightness = 0x80,
+			BlendMode blend = kBlendOpaque) const;
+	void drawShield(Graphics::Surface &surface, int shield, int xOffset, int yOffset) const;
+
+	Common::Array<Texture> _textures;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 66b16c4f780..c91e53f3756 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -183,6 +183,7 @@ MODULE_OBJS += \
 	insane/rebel2/psx/model.o \
 	insane/rebel2/psx/psx.o \
 	insane/rebel2/psx/resource.o \
+	insane/rebel2/psx/ui.o \
 	insane/rebel2/psx/video.o
 endif
 


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

Commit Message:
SCUMM: RA2: basic gamepad controls and laser rendering in psx

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


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 035dba8a892..b559ef6315a 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -18,6 +18,7 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include "common/config-manager.h"
 #include "common/events.h"
 #include "common/random.h"
 #include "common/system.h"
@@ -69,6 +70,23 @@ struct RA2PSXLevel1Explosion {
 	int y;
 };
 
+struct RA2PSXLevel1Shot {
+	RA2PSXLevel1Shot() : active(false), progress(0), targetX(0), targetY(0) {}
+
+	bool active;
+	int progress;
+	float targetX;
+	float targetY;
+};
+
+static 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 };
+
 static void updateLevel1Enemy(RA2PSXLevel1Enemy &enemy) {
 	const float t = MIN(1.0f, (float)enemy.age / enemy.lifetime);
 	const float inverse = 1.0f - t;
@@ -110,10 +128,106 @@ static void spawnLevel1Enemy(RA2PSXLevel1Enemy &enemy, Common::RandomSource &ran
 	updateLevel1Enemy(enemy);
 }
 
+static 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;
+	if (up && down)
+		up = down = false;
+
+	if (!left && !right) {
+		directionX = 0;
+		velocityX /= 2;
+	} else {
+		if (left) {
+			if (velocityX > 0)
+				velocityX = -velocityX / 4;
+			velocityX = MAX(-4096, velocityX - 448);
+			directionX = -1;
+		}
+		if (right) {
+			if (velocityX < 0)
+				velocityX = -velocityX / 4;
+			velocityX = MIN(4096, velocityX + 448);
+			directionX = 1;
+		}
+		if ((up || down) && ABS(velocityX) < ABS(velocityY) / 2)
+			velocityX = ABS(velocityY) * directionX / 2;
+	}
+	x = CLIP<int>(x + velocityX / 512, 30, 290);
+
+	if (!up && !down) {
+		directionY = 0;
+		velocityY /= 2;
+	} else {
+		if (up) {
+			if (velocityY > 0)
+				velocityY = -velocityY / 4;
+			velocityY = MAX(-4096, velocityY - 448);
+			directionY = -1;
+		}
+		if (down) {
+			if (velocityY < 0)
+				velocityY = -velocityY / 4;
+			velocityY = MIN(4096, velocityY + 448);
+			directionY = 1;
+		}
+		if ((left || right) && ABS(velocityX) / 2 > ABS(velocityY))
+			velocityY = ABS(velocityX) * directionY / 2;
+	}
+	y = CLIP<int>(y + velocityY / 512, 48, 178);
+}
+
+static void spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY) {
+	int slot = -1;
+	for (int i = 0; i < 8; ++i) {
+		if (!shots[i].active) {
+			slot = i;
+			break;
+		}
+	}
+	if (slot < 0)
+		return;
+
+	shots[slot].active = true;
+	shots[slot].progress = 400;
+	shots[slot].targetX = (aimX - 160) * 18000.0f / 640.0f;
+	shots[slot].targetY = (aimY - 120) * 18000.0f / 640.0f;
+}
+
+static void updateLevel1Shots(RA2PSXLevel1Shot *shots) {
+	for (int i = 0; i < 8; ++i) {
+		if (!shots[i].active)
+			continue;
+		shots[i].progress += 200;
+		if (shots[i].progress > 4399)
+			shots[i].active = false;
+	}
+}
+
+static void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel &laser,
+		const RA2PSXLevel1Shot *shots) {
+	for (int shotIndex = 0; shotIndex < 8; ++shotIndex) {
+		const RA2PSXLevel1Shot &shot = shots[shotIndex];
+		if (!shot.active || shot.progress >= 4000)
+			continue;
+		const float progress = shot.progress / 4096.0f;
+		for (int laserIndex = 0; laserIndex < 3; ++laserIndex) {
+			const float *start = kLevel1LaserStart[laserIndex];
+			const float directionX = shot.targetX - start[0];
+			const float directionY = shot.targetY - start[1];
+			const float directionZ = 18000.0f - start[2];
+			renderer.renderPerspectiveModel(laser,
+					start[0] + directionX * progress,
+					start[1] + directionY * progress,
+					start[2] + directionZ * progress,
+					directionX, directionY, directionZ, kLevel1LaserRoll[laserIndex], false);
+		}
+	}
+}
+
 static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
-		const RA2PSXLevel1Enemy *enemies, const RA2PSXLevel1Explosion *explosions,
-		int aimX, int aimY, int fireFrames) {
-	const uint32 red = surface.format.RGBToColor(255, 48, 32);
+		const RA2PSXLevel1Enemy *enemies, const RA2PSXLevel1Explosion *explosions) {
 	const uint32 green = surface.format.RGBToColor(64, 255, 96);
 
 	for (int i = 0; i < 3; ++i) {
@@ -127,19 +241,16 @@ static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &
 			ui.drawExplosion(surface, explosions[i].x, explosions[i].y,
 					10 - explosions[i].frames);
 	}
-
-	if (fireFrames > 0) {
-		surface.drawLine(38, surface.h - 1, aimX - 2, aimY, red);
-		surface.drawLine(surface.w - 39, surface.h - 1, aimX + 2, aimY, red);
-	}
 }
 #endif
 
 Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
-		const RA2PSXModel &crosshair, const RA2PSXLevel1UI &ui, int lives, int &score) {
+		const RA2PSXModel &crosshair, const RA2PSXModel &laser,
+		const RA2PSXLevel1UI &ui, int lives, int &score) {
 #ifndef USE_TINYGL
 	(void)model;
 	(void)crosshair;
+	(void)laser;
 	(void)ui;
 	(void)lives;
 	(void)score;
@@ -163,24 +274,37 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 
 	RA2PSXLevel1Enemy enemies[3];
 	RA2PSXLevel1Explosion explosions[3];
+	RA2PSXLevel1Shot shots[8];
 	int aimX = 160;
 	int aimY = 113;
+	int aimVelocityX = 0;
+	int aimVelocityY = 0;
+	int aimDirectionX = 0;
+	int aimDirectionY = 0;
 	int shield = 100;
 	int spawnDelay = 0;
 	int spawnRange = 80;
 	int spawnBase = 60;
 	int nextSpawnAdjustment = 0;
 	int lastFrame = -1;
-	int lastShotFrame = -10;
-	int fireFrames = 0;
+	int lastShotFrame = -4;
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
 	bool moveDown = false;
+	bool actionLeft = false;
+	bool actionRight = false;
+	bool actionUp = false;
+	bool actionDown = false;
+	int joystickAxisX = 0;
+	int joystickAxisY = 0;
 	bool mouseFire = false;
 	bool keyFire = false;
+	bool actionFire = false;
 	bool fireRequested = false;
 	Level1Result result = kLevel1Complete;
+	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
+			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
 
 	const bool cursorWasVisible = CursorMan.isVisible();
 	CursorMan.showMouse(false);
@@ -194,6 +318,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 			case Common::EVENT_MOUSEMOVE:
 				aimX = CLIP<int>(event.mouse.x, 30, 290);
 				aimY = CLIP<int>(event.mouse.y, 48, 178);
+				aimVelocityX = aimVelocityY = 0;
+				aimDirectionX = aimDirectionY = 0;
 				break;
 			case Common::EVENT_LBUTTONDOWN:
 				aimX = CLIP<int>(event.mouse.x, 30, 290);
@@ -238,6 +364,61 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 						event.kbd.keycode == Common::KEYCODE_RETURN)
 					keyFire = false;
 				break;
+			case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+			case Common::EVENT_CUSTOM_ENGINE_ACTION_END: {
+				const bool pressed = event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START;
+				switch (event.customType) {
+				case kScummActionInsaneLeft:
+					actionLeft = pressed;
+					break;
+				case kScummActionInsaneRight:
+					actionRight = pressed;
+					break;
+				case kScummActionInsaneUp:
+					actionUp = pressed;
+					break;
+				case kScummActionInsaneDown:
+					actionDown = pressed;
+					break;
+				case kScummActionInsaneAttack:
+					actionFire = pressed;
+					if (pressed)
+						fireRequested = true;
+					break;
+				case kScummActionInsaneBack:
+					if (pressed)
+						result = kLevel1Quit;
+					break;
+				default:
+					break;
+				}
+				break;
+			}
+			case Common::EVENT_CUSTOM_BACKEND_ACTION_AXIS: {
+				const int axisPosition = event.joystick.position == Common::JOYAXIS_MIN ?
+						Common::JOYAXIS_MAX : event.joystick.position;
+				switch (event.customType) {
+				case kScummBackendActionRebel2AxisUp:
+					if (event.joystick.position != 0 || joystickAxisY <= 0)
+						joystickAxisY = -axisPosition;
+					break;
+				case kScummBackendActionRebel2AxisDown:
+					if (event.joystick.position != 0 || joystickAxisY >= 0)
+						joystickAxisY = axisPosition;
+					break;
+				case kScummBackendActionRebel2AxisLeft:
+					if (event.joystick.position != 0 || joystickAxisX <= 0)
+						joystickAxisX = -axisPosition;
+					break;
+				case kScummBackendActionRebel2AxisRight:
+					if (event.joystick.position != 0 || joystickAxisX >= 0)
+						joystickAxisX = axisPosition;
+					break;
+				default:
+					break;
+				}
+				break;
+			}
 			case Common::EVENT_QUIT:
 			case Common::EVENT_RETURN_TO_LAUNCHER:
 				_vm->quitGame();
@@ -260,10 +441,12 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 			const int currentFrame = decoder.getCurFrame();
 			while (lastFrame < currentFrame && shield > 0) {
 				++lastFrame;
-				aimX = CLIP<int>(aimX + ((int)moveRight - (int)moveLeft) * 6,
-						30, 290);
-				aimY = CLIP<int>(aimY + ((int)moveDown - (int)moveUp) * 6,
-						48, 178);
+				updateLevel1Aim(aimX, aimY, aimVelocityX, aimVelocityY,
+						aimDirectionX, aimDirectionY,
+						moveLeft || actionLeft || joystickAxisX < -joystickDeadzone,
+						moveRight || actionRight || joystickAxisX > joystickDeadzone,
+						moveUp || actionUp || joystickAxisY < -joystickDeadzone,
+						moveDown || actionDown || joystickAxisY > joystickDeadzone);
 
 				if (lastFrame >= nextSpawnAdjustment) {
 					nextSpawnAdjustment = lastFrame + 20;
@@ -285,8 +468,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 					spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
 				}
 
-				if (fireFrames > 0)
-					--fireFrames;
+				updateLevel1Shots(shots);
 				for (int i = 0; i < 3; ++i) {
 					if (explosions[i].frames > 0)
 						--explosions[i].frames;
@@ -315,12 +497,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 				break;
 			}
 
-			const bool heldFire = mouseFire || keyFire;
-			if ((fireRequested || (heldFire && currentFrame - lastShotFrame >= 5)) &&
-					currentFrame - lastShotFrame >= 3) {
+			const bool heldFire = mouseFire || keyFire || actionFire;
+			const bool shootRequested = fireRequested ||
+					(heldFire && currentFrame - lastShotFrame >= 12);
+			fireRequested = false;
+			if (shootRequested && currentFrame - lastShotFrame >= 4) {
 				lastShotFrame = currentFrame;
-				fireRequested = false;
-				fireFrames = 2;
+				spawnLevel1Shot(shots, aimX, aimY);
 				int hitEnemy = -1;
 				float hitDistance = 1000000.0f;
 				for (int i = 0; i < 3; ++i) {
@@ -355,11 +538,15 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 					renderer.renderModel(model, enemies[i].x, enemies[i].y, enemies[i].size,
 							enemies[i].pitch, enemies[i].yaw, enemies[i].roll);
 			}
+			renderLevel1Shots(renderer, laser, shots);
+			const float crosshairX = (aimX - 160) * 620.0f / 640.0f;
+			const float crosshairY = (aimY - 120) * 620.0f / 640.0f;
 			renderer.renderModel(crosshair, aimX, aimY, 31.0f,
-					(aimY - 113) * 0.12f, -(aimX - 160) * 0.10f, 0.0f, false);
+					crosshairY * 720.0f / 4096.0f,
+					-crosshairX * 720.0f / 4096.0f, 0.0f, false);
 			Graphics::Surface output;
 			renderer.finishFrame(output);
-			drawLevel1Effects(output, ui, enemies, explosions, aimX, aimY, fireFrames);
+			drawLevel1Effects(output, ui, enemies, explosions);
 			ui.drawCockpit(output);
 			ui.drawHUD(output, score, lives, shield, currentFrame);
 			g_system->copyRectToScreen(output.getPixels(), output.pitch, 0, 0, output.w, output.h);
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index aadde528877..2fa82f17e0b 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -240,6 +240,90 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 		tglEnable(TGL_DEPTH_TEST);
 }
 
+void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
+		float x, float y, float z, float directionX, float directionY, float directionZ,
+		float roll, bool depthTest) {
+	if (!_context || model.vertices().empty())
+		return;
+
+	const float directionLength = sqrtf(directionX * directionX + directionY * directionY +
+			directionZ * directionZ);
+	if (directionLength < 0.001f)
+		return;
+	const float forwardX = directionX / directionLength;
+	const float forwardY = directionY / directionLength;
+	const float forwardZ = directionZ / directionLength;
+
+	float rightX = forwardZ;
+	float rightY = 0.0f;
+	float rightZ = -forwardX;
+	const float rightLength = sqrtf(rightX * rightX + rightZ * rightZ);
+	if (rightLength < 0.001f) {
+		rightX = 1.0f;
+		rightZ = 0.0f;
+	} else {
+		rightX /= rightLength;
+		rightZ /= rightLength;
+	}
+	const float downX = forwardY * rightZ - forwardZ * rightY;
+	const float downY = forwardZ * rightX - forwardX * rightZ;
+	const float downZ = forwardX * rightY - forwardY * rightX;
+	const float angle = roll * 0.017453292519943295f;
+	const float cosine = cosf(angle);
+	const float sine = sinf(angle);
+	const float modelXx = rightX * cosine + downX * sine;
+	const float modelXy = rightY * cosine + downY * sine;
+	const float modelXz = rightZ * cosine + downZ * sine;
+	const float modelYx = downX * cosine - rightX * sine;
+	const float modelYy = downY * cosine - rightY * sine;
+	const float modelYz = downZ * cosine - rightZ * sine;
+
+	struct ProjectedVertex {
+		float x;
+		float y;
+		bool visible;
+	};
+	Common::Array<ProjectedVertex> projected;
+	projected.resize(model.vertices().size());
+	const float centerX = _width * 0.5f;
+	const float centerY = _height * 0.5f;
+	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;
+		projected[i].visible = worldZ > 1.0f;
+		if (projected[i].visible) {
+			projected[i].x = centerX + worldX * focalLength / worldZ;
+			projected[i].y = centerY + worldY * focalLength / worldZ;
+		}
+	}
+
+	TinyGL::setContext(_context);
+	if (!depthTest)
+		tglDisable(TGL_DEPTH_TEST);
+	const Common::Array<RA2PSXFace> &faces = model.faces();
+	for (uint faceIndex = 0; faceIndex < faces.size(); ++faceIndex) {
+		const RA2PSXFace &face = faces[faceIndex];
+		bool visible = true;
+		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex)
+			visible &= projected[face.vertex[vertexIndex]].visible;
+		if (!visible)
+			continue;
+
+		tglColor3ub(face.r, face.g, face.b);
+		tglBegin(face.vertexCount == 4 ? TGL_QUADS : TGL_TRIANGLES);
+		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
+			const ProjectedVertex &vertex = projected[face.vertex[vertexIndex]];
+			tglVertex3f(vertex.x, vertex.y, 0.0f);
+		}
+		tglEnd();
+	}
+	if (!depthTest)
+		tglEnable(TGL_DEPTH_TEST);
+}
+
 void RA2PSXTinyGLRenderer::finishFrame(Graphics::Surface &surface) {
 	if (!_context)
 		return;
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index d78be61c0df..4e31bb946db 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -115,7 +115,7 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 }
 
 bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
-		RA2PSXLevel1UI &ui) {
+		RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -127,8 +127,10 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
 
 	Common::Array<byte> modelData;
 	Common::Array<byte> crosshairData;
+	Common::Array<byte> laserData;
 	return archive.getMember("fOFS/TieFighter/main", modelData) && model.load(modelData) &&
 			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
+			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
 			ui.load(archive);
 }
 
@@ -136,8 +138,9 @@ Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
 	RA2PSXModel model;
 	RA2PSXModel crosshair;
+	RA2PSXModel laser;
 	RA2PSXLevel1UI ui;
-	if (!loadLevel1Assets(model, crosshair, ui))
+	if (!loadLevel1Assets(model, crosshair, laser, ui))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
@@ -151,7 +154,7 @@ Common::Error Rebel2PSX::runGame() {
 	int lives = 3;
 	int score = 0;
 	while (!_vm->shouldQuit()) {
-		const Level1Result result = playLevel1(model, crosshair, ui, lives, score);
+		const Level1Result result = playLevel1(model, crosshair, laser, ui, lives, score);
 		if (result == kLevel1Quit)
 			return Common::kNoError;
 		if (result == kLevel1Error)
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 38aca961b99..075128b281d 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -99,6 +99,9 @@ public:
 	void beginFrame(const Graphics::Surface &background);
 	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,
+			float directionX, float directionY, float directionZ, float roll,
+			bool depthTest = true);
 	void finishFrame(Graphics::Surface &surface);
 
 private:
@@ -126,9 +129,10 @@ private:
 	Common::SeekableReadStream *openResource(int number);
 	Common::SeekableReadStream *openRawFile(const Common::Path &path, int discNumber);
 	bool playVideo(const Common::Path &path, int discNumber, bool version2);
-	bool loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair, RA2PSXLevel1UI &ui);
+	bool loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
+			RA2PSXModel &laser, RA2PSXLevel1UI &ui);
 	Level1Result playLevel1(const RA2PSXModel &model, const RA2PSXModel &crosshair,
-			const RA2PSXLevel1UI &ui, int lives, int &score);
+			const RA2PSXModel &laser, const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
 };


Commit: 0d7833eaaaf8ae3b275d5aacbaec29806bd5eeb9
    https://github.com/scummvm/scummvm/commit/0d7833eaaaf8ae3b275d5aacbaec29806bd5eeb9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: third person ship view in psx

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


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index b559ef6315a..4451015fbe0 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -37,6 +37,8 @@
 namespace Scumm {
 
 #ifdef USE_TINYGL
+static const int kLevel1FrameRate = 30;
+
 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),
@@ -71,12 +73,25 @@ struct RA2PSXLevel1Explosion {
 };
 
 struct RA2PSXLevel1Shot {
-	RA2PSXLevel1Shot() : active(false), progress(0), targetX(0), targetY(0) {}
+	RA2PSXLevel1Shot() : active(false), progress(0), targetX(0), targetY(0), targetZ(0) {}
 
 	bool active;
 	int progress;
+	float start[3][3];
+	float roll[3];
 	float targetX;
 	float targetY;
+	float targetZ;
+};
+
+struct RA2PSXLevel1Ship {
+	RA2PSXLevel1Ship() : x(0), y(0), z(1000), velocityX(0), velocityY(0) {}
+
+	int x;
+	int y;
+	int z;
+	int velocityX;
+	int velocityY;
 };
 
 static const float kLevel1LaserStart[3][3] = {
@@ -87,6 +102,12 @@ static const float kLevel1LaserStart[3][3] = {
 
 static const float kLevel1LaserRoll[3] = { -45.0f, 45.0f, 0.0f };
 
+static 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;
@@ -178,7 +199,72 @@ static void updateLevel1Aim(int &x, int &y, int &velocityX, int &velocityY,
 	y = CLIP<int>(y + velocityY / 512, 48, 178);
 }
 
-static void spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY) {
+static void updateLevel1Ship(RA2PSXLevel1Ship &ship,
+		bool left, bool right, bool up, bool down) {
+	if (left == right) {
+		ship.velocityX = ship.velocityX * 3 / 4;
+	} else if (left) {
+		ship.velocityX = MAX(-4096, ship.velocityX - 400);
+	} else {
+		ship.velocityX = MIN(4096, ship.velocityX + 400);
+	}
+
+	if (up == down) {
+		ship.velocityY = ship.velocityY * 3 / 4;
+	} else if (up) {
+		ship.velocityY = MAX(-4096, ship.velocityY - 400);
+	} else {
+		ship.velocityY = MIN(4096, ship.velocityY + 400);
+	}
+
+	ship.x = CLIP<int>(ship.x + ship.velocityX / 16 / 25, -282, 282);
+	ship.y = CLIP<int>(ship.y + ship.velocityY * 10 / 4096, -142, 157);
+}
+
+static 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 yaw = -bank * 0.5f * 0.017453292519943295f;
+	forwardX = sinf(yaw);
+	forwardY = 0.0f;
+	forwardZ = -cosf(yaw);
+	roll = bank;
+}
+
+static void transformLevel1ShipPoint(const RA2PSXLevel1Ship &ship,
+		float localX, float localY, float localZ,
+		float &worldX, float &worldY, float &worldZ) {
+	float forwardX;
+	float forwardY;
+	float forwardZ;
+	float roll;
+	getLevel1ShipOrientation(ship, forwardX, forwardY, forwardZ, roll);
+
+	float rightX = forwardZ;
+	float rightZ = -forwardX;
+	const float rightLength = sqrtf(rightX * rightX + rightZ * rightZ);
+	rightX /= rightLength;
+	rightZ /= rightLength;
+	const float downX = forwardY * rightZ;
+	const float downY = forwardZ * rightX - forwardX * rightZ;
+	const float downZ = -forwardY * rightX;
+	const float angle = roll * 0.017453292519943295f;
+	const float cosine = cosf(angle);
+	const float sine = sinf(angle);
+	const float modelXx = rightX * cosine + downX * sine;
+	const float modelXy = downY * sine;
+	const float modelXz = rightZ * cosine + downZ * sine;
+	const float modelYx = downX * cosine - rightX * sine;
+	const float modelYy = downY * cosine;
+	const float modelYz = downZ * cosine - rightZ * sine;
+
+	worldX = ship.x + modelXx * localX + modelYx * localY + forwardX * localZ;
+	worldY = ship.y + modelXy * localX + modelYy * localY + forwardY * localZ;
+	worldZ = ship.z + modelXz * localX + modelYz * localY + forwardZ * localZ;
+}
+
+static bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
+		const RA2PSXLevel1Ship *ship, int &targetScreenX, int &targetScreenY) {
 	int slot = -1;
 	for (int i = 0; i < 8; ++i) {
 		if (!shots[i].active) {
@@ -187,12 +273,42 @@ static void spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY) {
 		}
 	}
 	if (slot < 0)
-		return;
+		return false;
+
+	RA2PSXLevel1Shot &shot = shots[slot];
+	shot.active = true;
+	shot.progress = 400;
+	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.targetZ = 18000.0f;
+	} else {
+		float forwardX;
+		float forwardY;
+		float forwardZ;
+		float shipRoll;
+		getLevel1ShipOrientation(*ship, forwardX, forwardY, forwardZ, shipRoll);
+		for (int i = 0; i < 3; ++i) {
+			transformLevel1ShipPoint(*ship, kLevel1ShipLaserStart[i][0],
+					kLevel1ShipLaserStart[i][1], kLevel1ShipLaserStart[i][2],
+					shot.start[i][0], shot.start[i][1], shot.start[i][2]);
+			shot.roll[i] = kLevel1LaserRoll[i] + shipRoll;
+		}
+		transformLevel1ShipPoint(*ship, 0.0f, -70.0f, -100.0f,
+				shot.targetX, shot.targetY, shot.targetZ);
+		shot.targetX -= forwardX * 18000.0f;
+		shot.targetY -= forwardY * 18000.0f;
+		shot.targetZ -= forwardZ * 18000.0f;
+	}
 
-	shots[slot].active = true;
-	shots[slot].progress = 400;
-	shots[slot].targetX = (aimX - 160) * 18000.0f / 640.0f;
-	shots[slot].targetY = (aimY - 120) * 18000.0f / 640.0f;
+	targetScreenX = 160 + (int)(shot.targetX * 640.0f / shot.targetZ);
+	targetScreenY = 120 + (int)(shot.targetY * 640.0f / shot.targetZ);
+	return true;
 }
 
 static void updateLevel1Shots(RA2PSXLevel1Shot *shots) {
@@ -213,29 +329,30 @@ static void renderLevel1Shots(RA2PSXTinyGLRenderer &renderer, const RA2PSXModel
 			continue;
 		const float progress = shot.progress / 4096.0f;
 		for (int laserIndex = 0; laserIndex < 3; ++laserIndex) {
-			const float *start = kLevel1LaserStart[laserIndex];
+			const float *start = shot.start[laserIndex];
 			const float directionX = shot.targetX - start[0];
 			const float directionY = shot.targetY - start[1];
-			const float directionZ = 18000.0f - start[2];
+			const float directionZ = shot.targetZ - start[2];
 			renderer.renderPerspectiveModel(laser,
 					start[0] + directionX * progress,
 					start[1] + directionY * progress,
 					start[2] + directionZ * progress,
-					directionX, directionY, directionZ, kLevel1LaserRoll[laserIndex], false);
+					directionX, directionY, directionZ, shot.roll[laserIndex], false);
 		}
 	}
 }
 
 static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &ui,
-		const RA2PSXLevel1Enemy *enemies, const RA2PSXLevel1Explosion *explosions) {
+		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,
-					145, surface.h - 1, green);
+					laserTargetX - 15, laserTargetY, green);
 			surface.drawLine((int)enemies[i].x + 2, (int)enemies[i].y,
-					175, surface.h - 1, green);
+					laserTargetX + 15, laserTargetY, green);
 		}
 		if (explosions[i].frames > 0)
 			ui.drawExplosion(surface, explosions[i].x, explosions[i].y,
@@ -244,11 +361,12 @@ static void drawLevel1Effects(Graphics::Surface &surface, const RA2PSXLevel1UI &
 }
 #endif
 
-Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
-		const RA2PSXModel &crosshair, const RA2PSXModel &laser,
-		const RA2PSXLevel1UI &ui, int lives, int &score) {
+Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
+		const RA2PSXModel &shipModel, const RA2PSXModel &crosshair,
+		const RA2PSXModel &laser, const RA2PSXLevel1UI &ui, int lives, int &score) {
 #ifndef USE_TINYGL
-	(void)model;
+	(void)enemyModel;
+	(void)shipModel;
 	(void)crosshair;
 	(void)laser;
 	(void)ui;
@@ -275,6 +393,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 	RA2PSXLevel1Enemy enemies[3];
 	RA2PSXLevel1Explosion explosions[3];
 	RA2PSXLevel1Shot shots[8];
+	RA2PSXLevel1Ship ship;
 	int aimX = 160;
 	int aimY = 113;
 	int aimVelocityX = 0;
@@ -286,7 +405,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 	int spawnRange = 80;
 	int spawnBase = 60;
 	int nextSpawnAdjustment = 0;
-	int lastFrame = -1;
+	int logicFrame = -1;
+	int videoFrame = -1;
 	int lastShotFrame = -4;
 	bool moveLeft = false;
 	bool moveRight = false;
@@ -302,28 +422,41 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 	bool keyFire = false;
 	bool actionFire = false;
 	bool fireRequested = false;
+	bool thirdPersonView = true;
 	Level1Result result = kLevel1Complete;
 	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
 			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
 
 	const bool cursorWasVisible = CursorMan.isVisible();
 	CursorMan.showMouse(false);
-	g_system->warpMouse(aimX, aimY);
+	g_system->warpMouse(160, 120);
 	decoder.start();
+	const uint32 gameplayStartTime = g_system->getMillis();
+	const Graphics::Surface *background = nullptr;
 
 	while (!_vm->shouldQuit() && !decoder.endOfVideo()) {
+		bool redraw = false;
+		bool toggleViewRequested = false;
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
-				aimX = CLIP<int>(event.mouse.x, 30, 290);
-				aimY = CLIP<int>(event.mouse.y, 48, 178);
-				aimVelocityX = aimVelocityY = 0;
-				aimDirectionX = aimDirectionY = 0;
+				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.velocityX = ship.velocityY = 0;
+				} else {
+					aimX = CLIP<int>(event.mouse.x, 30, 290);
+					aimY = CLIP<int>(event.mouse.y, 48, 178);
+					aimVelocityX = aimVelocityY = 0;
+					aimDirectionX = aimDirectionY = 0;
+				}
 				break;
 			case Common::EVENT_LBUTTONDOWN:
-				aimX = CLIP<int>(event.mouse.x, 30, 290);
-				aimY = CLIP<int>(event.mouse.y, 48, 178);
+				if (!thirdPersonView) {
+					aimX = CLIP<int>(event.mouse.x, 30, 290);
+					aimY = CLIP<int>(event.mouse.y, 48, 178);
+				}
 				mouseFire = true;
 				fireRequested = true;
 				break;
@@ -333,6 +466,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 			case Common::EVENT_KEYDOWN:
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					result = kLevel1Quit;
+				} else if (event.kbd.keycode == Common::KEYCODE_TAB && !event.kbdRepeat) {
+					toggleViewRequested = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_LEFT ||
 						event.kbd.keycode == Common::KEYCODE_a) {
 					moveLeft = true;
@@ -385,6 +520,10 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 					if (pressed)
 						fireRequested = true;
 					break;
+				case kScummActionInsaneSwitch:
+					if (pressed)
+						toggleViewRequested = true;
+					break;
 				case kScummActionInsaneBack:
 					if (pressed)
 						result = kLevel1Quit;
@@ -428,89 +567,108 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 				break;
 			}
 		}
+		if (toggleViewRequested) {
+			thirdPersonView = !thirdPersonView;
+			ship.velocityX = ship.velocityY = 0;
+			aimX = 160;
+			aimY = 113;
+			aimVelocityX = aimVelocityY = 0;
+			aimDirectionX = aimDirectionY = 0;
+			g_system->warpMouse(160, thirdPersonView ? 120 : aimY);
+			redraw = true;
+		}
 		if (result == kLevel1Quit)
 			break;
 
-		if (decoder.needsUpdate()) {
-			const Graphics::Surface *frame = decoder.decodeNextFrame();
-			if (!frame) {
+		while (decoder.needsUpdate()) {
+			background = decoder.decodeNextFrame();
+			if (!background) {
 				result = kLevel1Error;
 				break;
 			}
+			videoFrame = decoder.getCurFrame();
+		}
+		if (result == kLevel1Error)
+			break;
 
-			const int currentFrame = decoder.getCurFrame();
-			while (lastFrame < currentFrame && shield > 0) {
-				++lastFrame;
+		const uint32 elapsed = g_system->getMillis() - gameplayStartTime;
+		const int targetLogicFrame = (int)((uint64)elapsed * kLevel1FrameRate / 1000);
+		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) {
+				updateLevel1Ship(ship, left, right, up, down);
+			} else {
 				updateLevel1Aim(aimX, aimY, aimVelocityX, aimVelocityY,
 						aimDirectionX, aimDirectionY,
-						moveLeft || actionLeft || joystickAxisX < -joystickDeadzone,
-						moveRight || actionRight || joystickAxisX > joystickDeadzone,
-						moveUp || actionUp || joystickAxisY < -joystickDeadzone,
-						moveDown || actionDown || joystickAxisY > joystickDeadzone);
-
-				if (lastFrame >= nextSpawnAdjustment) {
-					nextSpawnAdjustment = lastFrame + 20;
-					spawnRange = MAX(40, spawnRange - 1);
-					spawnBase = MAX(20, spawnBase - 1);
-				}
+						left, right, up, down);
+			}
 
-				int activeEnemies = 0;
-				for (int i = 0; i < 3; ++i)
-					activeEnemies += enemies[i].active ? 1 : 0;
-				--spawnDelay;
-				if (lastFrame < 1599 && activeEnemies < 3 && spawnDelay <= 0) {
-					for (int i = 0; i < 3; ++i) {
-						if (!enemies[i].active) {
-							spawnLevel1Enemy(enemies[i], _vm->_rnd);
-							break;
-						}
-					}
-					spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
-				}
+			if (logicFrame >= nextSpawnAdjustment) {
+				nextSpawnAdjustment = logicFrame + 20;
+				spawnRange = MAX(40, spawnRange - 1);
+				spawnBase = MAX(20, spawnBase - 1);
+			}
 
-				updateLevel1Shots(shots);
+			int activeEnemies = 0;
+			for (int i = 0; i < 3; ++i)
+				activeEnemies += enemies[i].active ? 1 : 0;
+			--spawnDelay;
+			if (videoFrame < 1599 && activeEnemies < 3 && spawnDelay <= 0) {
 				for (int i = 0; i < 3; ++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]);
-					if (enemies[i].age == enemies[i].fireFrame) {
-						enemies[i].laserFrames = 4;
-						if (_vm->_rnd.getRandomNumber(99) < 38)
-							shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
-					}
-					if (enemies[i].age >= enemies[i].lifetime) {
-						enemies[i].active = false;
-						if (_vm->_rnd.getRandomNumber(99) < 18)
-							shield = MAX(0, shield - 12);
+					if (!enemies[i].active) {
+						spawnLevel1Enemy(enemies[i], _vm->_rnd);
+						break;
 					}
 				}
+				spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
 			}
 
-			if (shield <= 0) {
-				result = kLevel1Death;
-				break;
+			updateLevel1Shots(shots);
+			for (int i = 0; i < 3; ++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]);
+				if (enemies[i].age == enemies[i].fireFrame) {
+					enemies[i].laserFrames = 4;
+					if (_vm->_rnd.getRandomNumber(99) < 38)
+						shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
+				}
+				if (enemies[i].age >= enemies[i].lifetime) {
+					enemies[i].active = false;
+					if (_vm->_rnd.getRandomNumber(99) < 18)
+						shield = MAX(0, shield - 12);
+				}
 			}
 
 			const bool heldFire = mouseFire || keyFire || actionFire;
 			const bool shootRequested = fireRequested ||
-					(heldFire && currentFrame - lastShotFrame >= 12);
+					(heldFire && logicFrame - lastShotFrame >= 12);
 			fireRequested = false;
-			if (shootRequested && currentFrame - lastShotFrame >= 4) {
-				lastShotFrame = currentFrame;
-				spawnLevel1Shot(shots, aimX, aimY);
+			if (shootRequested && logicFrame - lastShotFrame >= 4) {
+				int shotTargetX;
+				int shotTargetY;
+				if (!spawnLevel1Shot(shots, aimX, aimY,
+						thirdPersonView ? &ship : nullptr, shotTargetX, shotTargetY))
+					continue;
+				lastShotFrame = logicFrame;
 				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 - aimX;
-					const float dy = enemies[i].y - aimY;
+					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) {
@@ -531,24 +689,44 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &model,
 					}
 				}
 			}
+		}
+
+		if (shield <= 0) {
+			result = kLevel1Death;
+			break;
+		}
 
-			renderer.beginFrame(*frame);
+		if (background && redraw) {
+			renderer.beginFrame(*background);
 			for (int i = 0; i < 3; ++i) {
 				if (enemies[i].active)
-					renderer.renderModel(model, enemies[i].x, enemies[i].y, enemies[i].size,
+					renderer.renderModel(enemyModel, enemies[i].x, enemies[i].y, enemies[i].size,
 							enemies[i].pitch, enemies[i].yaw, enemies[i].roll);
 			}
 			renderLevel1Shots(renderer, laser, shots);
-			const float crosshairX = (aimX - 160) * 620.0f / 640.0f;
-			const float crosshairY = (aimY - 120) * 620.0f / 640.0f;
-			renderer.renderModel(crosshair, aimX, aimY, 31.0f,
-					crosshairY * 720.0f / 4096.0f,
-					-crosshairX * 720.0f / 4096.0f, 0.0f, false);
+			if (thirdPersonView) {
+				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);
+			} else {
+				const float crosshairX = (aimX - 160) * 620.0f / 640.0f;
+				const float crosshairY = (aimY - 120) * 620.0f / 640.0f;
+				renderer.renderModel(crosshair, aimX, aimY, 31.0f,
+						crosshairY * 720.0f / 4096.0f,
+						-crosshairX * 720.0f / 4096.0f, 0.0f, false);
+			}
 			Graphics::Surface output;
 			renderer.finishFrame(output);
-			drawLevel1Effects(output, ui, enemies, explosions);
-			ui.drawCockpit(output);
-			ui.drawHUD(output, score, lives, shield, currentFrame);
+			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);
+			if (!thirdPersonView)
+				ui.drawCockpit(output);
+			ui.drawHUD(output, score, lives, shield, 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/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 4e31bb946db..d92941bb883 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -114,8 +114,8 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 	return !_vm->shouldQuit();
 }
 
-bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
-		RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
+bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
+		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -125,10 +125,12 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
 	if (!loaded)
 		return false;
 
-	Common::Array<byte> modelData;
+	Common::Array<byte> enemyData;
+	Common::Array<byte> shipData;
 	Common::Array<byte> crosshairData;
 	Common::Array<byte> laserData;
-	return archive.getMember("fOFS/TieFighter/main", modelData) && model.load(modelData) &&
+	return archive.getMember("fOFS/TieFighter/main", enemyData) && enemy.load(enemyData) &&
+			archive.getMember("fOFS/Ship", shipData) && ship.load(shipData) &&
 			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
 			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
 			ui.load(archive);
@@ -136,11 +138,12 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
 
 Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
-	RA2PSXModel model;
+	RA2PSXModel enemy;
+	RA2PSXModel ship;
 	RA2PSXModel crosshair;
 	RA2PSXModel laser;
 	RA2PSXLevel1UI ui;
-	if (!loadLevel1Assets(model, crosshair, laser, ui))
+	if (!loadLevel1Assets(enemy, ship, crosshair, laser, ui))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
@@ -154,7 +157,7 @@ Common::Error Rebel2PSX::runGame() {
 	int lives = 3;
 	int score = 0;
 	while (!_vm->shouldQuit()) {
-		const Level1Result result = playLevel1(model, crosshair, laser, ui, lives, score);
+		const Level1Result result = playLevel1(enemy, ship, crosshair, laser, ui, lives, score);
 		if (result == kLevel1Quit)
 			return Common::kNoError;
 		if (result == kLevel1Error)
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 075128b281d..c9623c2a25e 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -129,10 +129,11 @@ private:
 	Common::SeekableReadStream *openResource(int number);
 	Common::SeekableReadStream *openRawFile(const Common::Path &path, int discNumber);
 	bool playVideo(const Common::Path &path, int discNumber, bool version2);
-	bool loadLevel1Assets(RA2PSXModel &model, RA2PSXModel &crosshair,
-			RA2PSXModel &laser, RA2PSXLevel1UI &ui);
-	Level1Result playLevel1(const RA2PSXModel &model, const RA2PSXModel &crosshair,
-			const RA2PSXModel &laser, const RA2PSXLevel1UI &ui, int lives, int &score);
+	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
+			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui);
+	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
+			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
+			const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
 };
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index a5ac6b9c203..7fc8bde06ec 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -1075,6 +1075,8 @@ Common::KeymapArray ScummMetaEngine::initKeymaps(const char *target) const {
 
 	Common::KeymapArray keymaps = MetaEngine::initKeymaps(target);
 	Common::String gameId = ConfMan.get("gameid", target);
+	const bool isRebel2PSX = gameId == "rebel2" &&
+			parsePlatform(ConfMan.get("platform", target)) == kPlatformPSX;
 	Action *act;
 
 	if (gameId == "rebel1" || gameId == "rebel2") {
@@ -1305,10 +1307,16 @@ Common::KeymapArray ScummMetaEngine::initKeymaps(const char *target) const {
 		act->addDefaultInputMapping("JOY_A");
 		rebel2Keymap->addAction(act);
 
-		act = new Action("RA2COVER", _("Cover"));
+		act = new Action("RA2COVER", isRebel2PSX ? _("Change view") : _("Cover"));
 		act->setCustomEngineActionEvent(kScummActionInsaneSwitch);
-		act->addDefaultInputMapping("JOY_X");
-		act->addDefaultInputMapping("JOY_Y");
+		if (isRebel2PSX) {
+			act->addDefaultInputMapping("TAB");
+			act->addDefaultInputMapping("JOY_Y");
+			act->addDefaultInputMapping("JOY_BACK");
+		} else {
+			act->addDefaultInputMapping("JOY_X");
+			act->addDefaultInputMapping("JOY_Y");
+		}
 		rebel2Keymap->addAction(act);
 
 		act = new Action("RA2SKIP", _("Skip / back"));


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

Commit Message:
SCUMM: RA2: load textures in psx

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
    engines/scumm/metaengine.cpp


diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index 4451015fbe0..ff7fe44fda6 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -240,27 +240,14 @@ static void transformLevel1ShipPoint(const RA2PSXLevel1Ship &ship,
 	float roll;
 	getLevel1ShipOrientation(ship, forwardX, forwardY, forwardZ, roll);
 
-	float rightX = forwardZ;
-	float rightZ = -forwardX;
-	const float rightLength = sqrtf(rightX * rightX + rightZ * rightZ);
-	rightX /= rightLength;
-	rightZ /= rightLength;
-	const float downX = forwardY * rightZ;
-	const float downY = forwardZ * rightX - forwardX * rightZ;
-	const float downZ = -forwardY * rightX;
 	const float angle = roll * 0.017453292519943295f;
 	const float cosine = cosf(angle);
 	const float sine = sinf(angle);
-	const float modelXx = rightX * cosine + downX * sine;
-	const float modelXy = downY * sine;
-	const float modelXz = rightZ * cosine + downZ * sine;
-	const float modelYx = downX * cosine - rightX * sine;
-	const float modelYy = downY * cosine;
-	const float modelYz = downZ * cosine - rightZ * sine;
-
-	worldX = ship.x + modelXx * localX + modelYx * localY + forwardX * localZ;
-	worldY = ship.y + modelXy * localX + modelYy * localY + forwardY * localZ;
-	worldZ = ship.z + modelXz * localX + modelYz * localY + forwardZ * localZ;
+	worldX = ship.x + forwardZ * cosine * localX - forwardZ * sine * localY +
+			forwardX * localZ;
+	worldY = ship.y + sine * localX + cosine * localY + forwardY * localZ;
+	worldZ = ship.z - forwardX * cosine * localX + forwardX * sine * localY +
+			forwardZ * localZ;
 }
 
 static bool spawnLevel1Shot(RA2PSXLevel1Shot *shots, int aimX, int aimY,
diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index 2fa82f17e0b..942ca1df5b1 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -12,6 +12,7 @@
 
 #include "common/endian.h"
 #include "common/system.h"
+#include "common/util.h"
 
 #include "scumm/insane/rebel2/psx/psx.h"
 
@@ -33,13 +34,91 @@ static bool readU32(const Common::Array<byte> &data, uint32 offset, uint32 &valu
 	return true;
 }
 
+bool loadRA2PSXTextures(const Common::Array<byte> &data,
+		Common::Array<RA2PSXTexture> &textures) {
+	const uint initialCount = textures.size();
+	uint32 offset = 0;
+	while (offset + 20 <= data.size()) {
+		Common::String name;
+		for (uint i = 0; i < 8 && data[offset + i]; ++i) {
+			if (data[offset + i] < 0x20 || data[offset + i] >= 0x7f)
+				return textures.size() > initialCount;
+			name += (char)data[offset + i];
+		}
+		if (name.empty())
+			break;
+
+		const uint16 recordSize = READ_LE_UINT16(data.data() + offset + 12);
+		const uint16 widthField = READ_LE_UINT16(data.data() + offset + 16);
+		const uint16 heightField = READ_LE_UINT16(data.data() + offset + 18);
+		const uint16 width = (widthField & 0xff) ? widthField & 0xff : 256;
+		const uint16 height = (heightField & 0xff) ? heightField & 0xff : 256;
+		const bool eightBit = (widthField & 0x100) != 0;
+		const uint32 paletteColors = eightBit ? 256 : 16;
+		const uint32 pixelCount = (uint32)width * height;
+		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)
+			break;
+
+		RA2PSXTexture texture;
+		texture.name = name;
+		texture.width = width;
+		texture.height = height;
+		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;
+		}
+		textures.push_back(texture);
+		offset += recordSize;
+	}
+	return textures.size() > initialCount;
+}
+
 RA2PSXModel::RA2PSXModel() : _radius(1.0f) {
 }
 
 bool RA2PSXModel::load(const Common::Array<byte> &data) {
 	_vertices.clear();
 	_faces.clear();
+	_textures.clear();
 	_radius = 1.0f;
+
+	uint32 textureOffset;
+	if (!readU32(data, 16, textureOffset) || textureOffset + 4 > data.size())
+		return false;
+	const uint32 textureCount = READ_LE_UINT32(data.data() + textureOffset);
+	if (textureCount > 256 || textureCount > (data.size() - textureOffset - 4) / 8)
+		return false;
+	for (uint32 i = 0; i < textureCount; ++i) {
+		const uint32 nameOffset = textureOffset + 4 + i * 8;
+		RA2PSXTexture texture;
+		for (uint j = 0; j < 8 && data[nameOffset + j]; ++j) {
+			if (data[nameOffset + j] < 0x20 || data[nameOffset + j] >= 0x7f)
+				return false;
+			texture.name += (char)data[nameOffset + j];
+		}
+		if (texture.name.empty())
+			return false;
+		texture.width = 0;
+		texture.height = 0;
+		_textures.push_back(texture);
+	}
+
 	if (!parseModel(data, 0, 0) || _vertices.empty() || _faces.empty())
 		return false;
 
@@ -55,6 +134,31 @@ bool RA2PSXModel::load(const Common::Array<byte> &data) {
 	return true;
 }
 
+bool RA2PSXModel::loadTextures(const Common::Array<byte> &data) {
+	Common::Array<RA2PSXTexture> decoded;
+	if (!loadRA2PSXTextures(data, decoded))
+		return false;
+
+	for (uint i = 0; i < _textures.size(); ++i) {
+		for (uint j = 0; j < decoded.size(); ++j) {
+			if (_textures[i].name.equalsIgnoreCase(decoded[j].name)) {
+				_textures[i] = decoded[j];
+				break;
+			}
+		}
+		if (_textures[i].pixels.empty())
+			return false;
+	}
+	return true;
+}
+
+const RA2PSXTexture *RA2PSXModel::texture(int index) const {
+	if (index < 0 || (uint)index >= _textures.size() || !_textures[index].width ||
+			!_textures[index].height || _textures[index].pixels.empty())
+		return nullptr;
+	return &_textures[index];
+}
+
 bool RA2PSXModel::parseModel(const Common::Array<byte> &data, uint32 modelOffset, int depth) {
 	if (depth > 8 || modelOffset + 8 > data.size())
 		return false;
@@ -102,6 +206,9 @@ bool RA2PSXModel::parseObject(const Common::Array<byte> &data, uint32 objectOffs
 		vertex.z = (int16)READ_LE_UINT16(source + 4);
 		_vertices.push_back(vertex);
 	}
+	const uint32 normalOffset = vertexOffset + (uint32)vertexCount * 8;
+	if (normalOffset > primitiveOffset)
+		return false;
 
 	Common::HashMap<uint16, bool> seen;
 	for (uint32 listIndex = 0; listIndex < 8; ++listIndex) {
@@ -126,8 +233,10 @@ bool RA2PSXModel::parseObject(const Common::Array<byte> &data, uint32 objectOffs
 			const uint32 record = primitiveOffset + (uint32)rawIndex * 32;
 			if (record + 32 > data.size())
 				return false;
-			const uint16 mode = READ_LE_UINT16(data.data() + record + 12) & 0x1f;
+			const byte mode = data[record + 12];
 			RA2PSXFace face = {};
+			face.mode = mode;
+			face.texture = -1;
 			face.vertexCount = (mode & 0x10) ? 4 : 3;
 			const uint16 offsets[4] = {
 				READ_LE_UINT16(data.data() + record),
@@ -140,11 +249,51 @@ bool RA2PSXModel::parseObject(const Common::Array<byte> &data, uint32 objectOffs
 					return false;
 				face.vertex[vertexIndex] = firstVertex + offsets[vertexIndex] / 8;
 			}
-			face.r = data[record + 16];
-			face.g = data[record + 17];
-			face.b = data[record + 18];
-			if (!(face.r | face.g | face.b))
-				face.r = face.g = face.b = 0x7f;
+			if (!(mode & 0x44)) {
+				const uint16 normalOffsets[4] = {
+					READ_LE_UINT16(data.data() + record + 6),
+					READ_LE_UINT16(data.data() + record + 8),
+					READ_LE_UINT16(data.data() + record + 10),
+					READ_LE_UINT16(data.data() + record + 26)
+				};
+				for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
+					const uint normalIndex = (mode & 1) ? vertexIndex : 0;
+					const uint16 sourceOffset = normalOffsets[normalIndex];
+					if ((sourceOffset & 7) || normalOffset + sourceOffset + 8 > primitiveOffset)
+						return false;
+					const byte *source = data.data() + normalOffset + sourceOffset;
+					face.normalX[vertexIndex] = (int16)READ_LE_UINT16(source);
+					face.normalY[vertexIndex] = (int16)READ_LE_UINT16(source + 2);
+					face.normalZ[vertexIndex] = (int16)READ_LE_UINT16(source + 4);
+				}
+			}
+			static const byte uvOffsets[4] = { 14, 20, 22, 28 };
+			for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
+				face.u[vertexIndex] = data[record + uvOffsets[vertexIndex]];
+				face.v[vertexIndex] = data[record + uvOffsets[vertexIndex] + 1];
+				face.r[vertexIndex] = data[record + 16];
+				face.g[vertexIndex] = data[record + 17];
+				face.b[vertexIndex] = data[record + 18];
+			}
+			if (mode & 0x40) {
+				face.r[1] = data[record + 6];
+				face.g[1] = data[record + 7];
+				face.b[1] = data[record + 8];
+				face.r[2] = data[record + 9];
+				face.g[2] = data[record + 10];
+				face.b[2] = data[record + 11];
+				if (face.vertexCount == 4) {
+					face.r[3] = data[record + 26];
+					face.g[3] = data[record + 27];
+					face.b[3] = data[record + 28];
+				}
+			}
+			if ((mode & 2) && data[record + 19] >= 0x80) {
+				const uint texture = data[record + 19] - 0x80;
+				if (texture >= _textures.size())
+					return false;
+				face.texture = texture;
+			}
 			_faces.push_back(face);
 		}
 		if (!terminated)
@@ -155,7 +304,8 @@ bool RA2PSXModel::parseObject(const Common::Array<byte> &data, uint32 objectOffs
 
 #ifdef USE_TINYGL
 
-RA2PSXTinyGLRenderer::RA2PSXTinyGLRenderer() : _context(nullptr), _width(0), _height(0) {
+RA2PSXTinyGLRenderer::RA2PSXTinyGLRenderer() : _context(nullptr), _activeTexture(nullptr),
+		_textureEnabled(false), _blendEnabled(false), _width(0), _height(0) {
 }
 
 RA2PSXTinyGLRenderer::~RA2PSXTinyGLRenderer() {
@@ -178,10 +328,128 @@ bool RA2PSXTinyGLRenderer::init(int width, int height) {
 	tglDepthFunc(TGL_LESS);
 	tglDisable(TGL_LIGHTING);
 	tglDisable(TGL_TEXTURE_2D);
+	tglDisable(TGL_ALPHA_TEST);
+	tglDisable(TGL_BLEND);
 	tglDisable(TGL_CULL_FACE);
+	tglAlphaFunc(TGL_GREATER, 0.0f);
+	tglBlendFunc(TGL_SRC_ALPHA, TGL_ONE_MINUS_SRC_ALPHA);
+	tglTexEnvi(TGL_TEXTURE_ENV, TGL_TEXTURE_ENV_MODE, TGL_MODULATE);
 	return true;
 }
 
+TGLuint RA2PSXTinyGLRenderer::getTextureId(const RA2PSXTexture &texture) {
+	for (uint i = 0; i < _textureBindings.size(); ++i) {
+		if (_textureBindings[i].texture == &texture)
+			return _textureBindings[i].id;
+	}
+
+	Common::Array<byte> rgba;
+	rgba.resize(texture.pixels.size() * 4);
+	for (uint i = 0; i < texture.pixels.size(); ++i) {
+		const uint32 pixel = texture.pixels[i];
+		rgba[i * 4] = (pixel >> 16) & 0xff;
+		rgba[i * 4 + 1] = (pixel >> 8) & 0xff;
+		rgba[i * 4 + 2] = pixel & 0xff;
+		rgba[i * 4 + 3] = !(pixel & 0x01000000) ? 0 :
+				(pixel & 0x02000000) ? 0x80 : 0xff;
+	}
+
+	TextureBinding binding;
+	binding.texture = &texture;
+	tglGenTextures(1, &binding.id);
+	tglBindTexture(TGL_TEXTURE_2D, binding.id);
+	tglTexParameteri(TGL_TEXTURE_2D, TGL_TEXTURE_MIN_FILTER, TGL_NEAREST);
+	tglTexParameteri(TGL_TEXTURE_2D, TGL_TEXTURE_MAG_FILTER, TGL_NEAREST);
+	tglTexParameteri(TGL_TEXTURE_2D, TGL_TEXTURE_WRAP_S, TGL_CLAMP_TO_EDGE);
+	tglTexParameteri(TGL_TEXTURE_2D, TGL_TEXTURE_WRAP_T, TGL_CLAMP_TO_EDGE);
+	tglTexImage2D(TGL_TEXTURE_2D, 0, TGL_RGBA, texture.width, texture.height, 0,
+			TGL_RGBA, TGL_UNSIGNED_BYTE, rgba.data());
+	_textureBindings.push_back(binding);
+	return binding.id;
+}
+
+void RA2PSXTinyGLRenderer::setFaceState(const RA2PSXModel &model, const RA2PSXFace &face) {
+	const RA2PSXTexture *texture = model.texture(face.texture);
+	if (texture) {
+		if (!_textureEnabled) {
+			tglEnable(TGL_TEXTURE_2D);
+			tglEnable(TGL_ALPHA_TEST);
+			_textureEnabled = true;
+		}
+		if (_activeTexture != texture) {
+			tglBindTexture(TGL_TEXTURE_2D, getTextureId(*texture));
+			_activeTexture = texture;
+		}
+	} else if (_textureEnabled) {
+		tglDisable(TGL_TEXTURE_2D);
+		tglDisable(TGL_ALPHA_TEST);
+		_textureEnabled = false;
+	}
+
+	const bool blend = (face.mode & 8) != 0;
+	if (blend != _blendEnabled) {
+		if (blend)
+			tglEnable(TGL_BLEND);
+		else
+			tglDisable(TGL_BLEND);
+		_blendEnabled = blend;
+	}
+}
+
+static int getRA2PSXDepthCue(float depth, float focalLength) {
+	if (depth <= 0.0f)
+		return 4096;
+	const int quotient = MIN(0x1ffff, (int)(focalLength * 65536.0f / depth));
+	return CLIP<int>((0x04440000LL - 28416LL * quotient) >> 12, 0, 4096);
+}
+
+void RA2PSXTinyGLRenderer::setFaceColor(const RA2PSXFace &face, uint vertexIndex,
+		float normalX, float normalY, float normalZ, float depth) {
+	int r = face.r[vertexIndex];
+	int g = face.g[vertexIndex];
+	int b = face.b[vertexIndex];
+	if (face.texture >= 0 && (face.mode & 4)) {
+		r = g = b = 0xff;
+	} else if (!(face.mode & 0x40)) {
+		static const int lightMatrix[3][3] = {
+			{ -3640, 1820, 455 },
+			{ 0, -4096, 0 },
+			{ -2816, 0, 1024 }
+		};
+		static const int colorMatrix[3][3] = {
+			{ 1648, 128, 2000 },
+			{ 1648, 128, 2000 },
+			{ 1648, 1024, 2000 }
+		};
+		const float normal[3] = { normalX, normalY, normalZ };
+		int light[3];
+		for (uint i = 0; i < 3; ++i) {
+			float value = 0.0f;
+			for (uint j = 0; j < 3; ++j)
+				value += lightMatrix[i][j] * normal[j];
+			light[i] = CLIP<int>((int)(value / 4096.0f), 0, 0x7fff);
+		}
+
+		int effect[3];
+		for (uint i = 0; i < 3; ++i) {
+			int value = 0;
+			for (uint j = 0; j < 3; ++j)
+				value += colorMatrix[i][j] * light[j];
+			effect[i] = 110 + MAX(0, value / 4096) / 16;
+		}
+		const int depthScale = 4096 - getRA2PSXDepthCue(depth, _width * 2.0f);
+		r = MIN(255, r * effect[0] / 256) * depthScale / 4096;
+		g = MIN(255, g * effect[1] / 256) * depthScale / 4096;
+		b = MIN(255, b * effect[2] / 256) * depthScale / 4096;
+	}
+	if (face.texture >= 0 && !(face.mode & 4)) {
+		r = MIN(r * 2, 0xff);
+		g = MIN(g * 2, 0xff);
+		b = MIN(b * 2, 0xff);
+	}
+	tglColor4ub(r, g, b, (face.texture < 0 && (face.mode & 8)) ? 0x80 : 0xff);
+}
+
 void RA2PSXTinyGLRenderer::beginFrame(const Graphics::Surface &background) {
 	if (!_context)
 		return;
@@ -222,14 +490,63 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 	tglRotatef(yaw, 0.0f, 1.0f, 0.0f);
 	const float scale = size / model.radius();
 	tglScalef(scale, -scale, scale);
+	const float pitchAngle = pitch * 0.017453292519943295f;
+	const float yawAngle = yaw * 0.017453292519943295f;
+	const float rollAngle = roll * 0.017453292519943295f;
+	const float pitchCosine = cosf(pitchAngle);
+	const float pitchSine = sinf(pitchAngle);
+	const float yawCosine = cosf(yawAngle);
+	const float yawSine = sinf(yawAngle);
+	const float rollCosine = cosf(rollAngle);
+	const float rollSine = sinf(rollAngle);
+	const float depth = model.radius() * (_width * 2.0f) / size;
 
 	const Common::Array<RA2PSXVertex> &vertices = model.vertices();
+	struct FacingVertex {
+		float x;
+		float y;
+	};
+	Common::Array<FacingVertex> facingVertices;
+	facingVertices.resize(vertices.size());
+	for (uint i = 0; i < vertices.size(); ++i) {
+		const float modelX = vertices[i].x;
+		const float modelY = -vertices[i].y;
+		const float modelZ = vertices[i].z;
+		const float yawX = yawCosine * modelX + yawSine * modelZ;
+		const float yawZ = -yawSine * modelX + yawCosine * modelZ;
+		const float pitchY = pitchCosine * modelY - pitchSine * yawZ;
+		facingVertices[i].x = rollCosine * yawX - rollSine * pitchY;
+		facingVertices[i].y = rollSine * yawX + rollCosine * pitchY;
+	}
+
 	const Common::Array<RA2PSXFace> &faces = model.faces();
 	for (uint faceIndex = 0; faceIndex < faces.size(); ++faceIndex) {
 		const RA2PSXFace &face = faces[faceIndex];
-		tglColor3ub(face.r, face.g, face.b);
+		if (!(face.mode & 0x60)) {
+			const FacingVertex &v0 = facingVertices[face.vertex[0]];
+			const FacingVertex &v1 = facingVertices[face.vertex[1]];
+			const FacingVertex &v2 = facingVertices[face.vertex[2]];
+			if (v0.x * (v1.y - v2.y) + v1.x * (v2.y - v0.y) +
+					v2.x * (v0.y - v1.y) <= 0.0f)
+				continue;
+		}
+		const RA2PSXTexture *texture = model.texture(face.texture);
+		setFaceState(model, face);
 		tglBegin(face.vertexCount == 4 ? TGL_QUADS : TGL_TRIANGLES);
 		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
+			const float normalX = face.normalX[vertexIndex];
+			const float normalY = -face.normalY[vertexIndex];
+			const float normalZ = face.normalZ[vertexIndex];
+			const float yawX = yawCosine * normalX + yawSine * normalZ;
+			const float yawZ = -yawSine * normalX + yawCosine * normalZ;
+			const float pitchY = pitchCosine * normalY - pitchSine * yawZ;
+			const float pitchZ = pitchSine * normalY + pitchCosine * yawZ;
+			setFaceColor(face, vertexIndex,
+					rollCosine * yawX - rollSine * pitchY,
+					rollSine * yawX + rollCosine * pitchY, pitchZ, depth);
+			if (texture)
+				tglTexCoord2f((face.u[vertexIndex] + 0.5f) / texture->width,
+						(face.v[vertexIndex] + 0.5f) / texture->height);
 			const RA2PSXVertex &vertex = vertices[face.vertex[vertexIndex]];
 			tglVertex3f((float)vertex.x, (float)vertex.y, (float)vertex.z);
 		}
@@ -281,6 +598,7 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 	struct ProjectedVertex {
 		float x;
 		float y;
+		float z;
 		bool visible;
 	};
 	Common::Array<ProjectedVertex> projected;
@@ -293,6 +611,7 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 		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;
+		projected[i].z = worldZ;
 		projected[i].visible = worldZ > 1.0f;
 		if (projected[i].visible) {
 			projected[i].x = centerX + worldX * focalLength / worldZ;
@@ -311,10 +630,33 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 			visible &= projected[face.vertex[vertexIndex]].visible;
 		if (!visible)
 			continue;
+		if (!(face.mode & 0x60)) {
+			const ProjectedVertex &v0 = projected[face.vertex[0]];
+			const ProjectedVertex &v1 = projected[face.vertex[1]];
+			const ProjectedVertex &v2 = projected[face.vertex[2]];
+			if (v0.x * (v1.y - v2.y) + v1.x * (v2.y - v0.y) +
+					v2.x * (v0.y - v1.y) <= 0.0f)
+				continue;
+		}
 
-		tglColor3ub(face.r, face.g, face.b);
+		const RA2PSXTexture *texture = model.texture(face.texture);
+		setFaceState(model, face);
+		const float faceDepth = projected[face.vertex[2]].z;
 		tglBegin(face.vertexCount == 4 ? TGL_QUADS : 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);
+			if (texture)
+				tglTexCoord2f((face.u[vertexIndex] + 0.5f) / texture->width,
+						(face.v[vertexIndex] + 0.5f) / texture->height);
 			const ProjectedVertex &vertex = projected[face.vertex[vertexIndex]];
 			tglVertex3f(vertex.x, vertex.y, 0.0f);
 		}
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index d92941bb883..6b37b003e46 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -129,8 +129,12 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 	Common::Array<byte> shipData;
 	Common::Array<byte> crosshairData;
 	Common::Array<byte> laserData;
+	Common::Array<byte> enemyTextureData;
+	Common::Array<byte> shipTextureData;
 	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) &&
+			archive.getMember("tex/BWingCockp", shipTextureData) && ship.loadTextures(shipTextureData) &&
 			archive.getMember("fOFS/CrosshairW", crosshairData) && crosshair.load(crosshairData) &&
 			archive.getMember("fOFS/WingLaser", laserData) && laser.load(laserData) &&
 			ui.load(archive);
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index c9623c2a25e..060a9a8d908 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -63,12 +63,29 @@ struct RA2PSXVertex {
 	int16 z;
 };
 
+struct RA2PSXTexture {
+	Common::String name;
+	uint16 width;
+	uint16 height;
+	Common::Array<uint32> pixels;
+};
+
+bool loadRA2PSXTextures(const Common::Array<byte> &data,
+		Common::Array<RA2PSXTexture> &textures);
+
 struct RA2PSXFace {
 	uint16 vertex[4];
+	int16 normalX[4];
+	int16 normalY[4];
+	int16 normalZ[4];
+	byte u[4];
+	byte v[4];
 	byte vertexCount;
-	byte r;
-	byte g;
-	byte b;
+	byte mode;
+	byte r[4];
+	byte g[4];
+	byte b[4];
+	int16 texture;
 };
 
 class RA2PSXModel {
@@ -76,8 +93,10 @@ public:
 	RA2PSXModel();
 
 	bool load(const Common::Array<byte> &data);
+	bool loadTextures(const Common::Array<byte> &data);
 	const Common::Array<RA2PSXVertex> &vertices() const { return _vertices; }
 	const Common::Array<RA2PSXFace> &faces() const { return _faces; }
+	const RA2PSXTexture *texture(int index) const;
 	float radius() const { return _radius; }
 
 private:
@@ -86,6 +105,7 @@ private:
 
 	Common::Array<RA2PSXVertex> _vertices;
 	Common::Array<RA2PSXFace> _faces;
+	Common::Array<RA2PSXTexture> _textures;
 	float _radius;
 };
 
@@ -105,7 +125,21 @@ public:
 	void finishFrame(Graphics::Surface &surface);
 
 private:
+	struct TextureBinding {
+		const RA2PSXTexture *texture;
+		TGLuint id;
+	};
+
+	void setFaceState(const RA2PSXModel &model, const RA2PSXFace &face);
+	void setFaceColor(const RA2PSXFace &face, uint vertexIndex,
+			float normalX, float normalY, float normalZ, float depth);
+	TGLuint getTextureId(const RA2PSXTexture &texture);
+
 	TinyGL::ContextHandle *_context;
+	Common::Array<TextureBinding> _textureBindings;
+	const RA2PSXTexture *_activeTexture;
+	bool _textureEnabled;
+	bool _blendEnabled;
 	int _width;
 	int _height;
 };
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index a34df27bfc9..cb8066f8b0a 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -10,10 +10,8 @@
  * (at your option) any later version.
  */
 
-#include "common/endian.h"
 #include "common/util.h"
 
-#include "scumm/insane/rebel2/psx/psx.h"
 #include "scumm/insane/rebel2/psx/ui.h"
 
 namespace Scumm {
@@ -50,59 +48,10 @@ static RA2PSXUIColor shieldColor(const RA2PSXUIGradientStop *stops, uint count,
 }
 
 bool RA2PSXLevel1UI::loadTextures(const Common::Array<byte> &data) {
-	const uint initialCount = _textures.size();
-	uint32 offset = 0;
-	while (offset + 20 <= data.size()) {
-		Common::String name;
-		for (uint i = 0; i < 8 && data[offset + i]; ++i) {
-			if (data[offset + i] < 0x20 || data[offset + i] >= 0x7f)
-				return _textures.size() > initialCount;
-			name += (char)data[offset + i];
-		}
-		if (name.empty())
-			break;
-
-		const uint16 recordSize = READ_LE_UINT16(data.data() + offset + 12);
-		const uint16 widthField = READ_LE_UINT16(data.data() + offset + 16);
-		const uint16 heightField = READ_LE_UINT16(data.data() + offset + 18);
-		const uint16 width = (widthField & 0xff) ? widthField & 0xff : 256;
-		const uint16 height = (heightField & 0xff) ? heightField & 0xff : 256;
-		const bool eightBit = (widthField & 0x100) != 0;
-		const uint32 paletteColors = eightBit ? 256 : 16;
-		const uint32 pixelCount = (uint32)width * height;
-		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)
-			break;
-
-		Texture texture;
-		texture.name = name;
-		texture.width = width;
-		texture.height = height;
-		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 | (r << 16) | (g << 8) | b;
-		}
-		_textures.push_back(texture);
-		offset += recordSize;
-	}
-	return _textures.size() > initialCount;
+	return loadRA2PSXTextures(data, _textures);
 }
 
-const RA2PSXLevel1UI::Texture *RA2PSXLevel1UI::findTexture(const char *name) const {
+const RA2PSXTexture *RA2PSXLevel1UI::findTexture(const char *name) const {
 	for (uint i = 0; i < _textures.size(); ++i) {
 		if (_textures[i].name.equalsIgnoreCase(name))
 			return &_textures[i];
@@ -131,7 +80,7 @@ bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
 
 void RA2PSXLevel1UI::drawTexture(Graphics::Surface &surface, const char *name,
 		int x, int y, const Common::Rect &source, int brightness, BlendMode blend) const {
-	const Texture *texture = findTexture(name);
+	const RA2PSXTexture *texture = findTexture(name);
 	if (!texture)
 		return;
 
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 1adbe99831f..6665d5875cd 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -13,16 +13,13 @@
 #ifndef SCUMM_INSANE_REBEL2_PSX_UI_H
 #define SCUMM_INSANE_REBEL2_PSX_UI_H
 
-#include "common/array.h"
 #include "common/rect.h"
-#include "common/str.h"
-#include "common/types.h"
 
 #include "graphics/surface.h"
 
-namespace Scumm {
+#include "scumm/insane/rebel2/psx/psx.h"
 
-class RA2PSXArchive;
+namespace Scumm {
 
 class RA2PSXLevel1UI {
 public:
@@ -38,21 +35,14 @@ private:
 		kBlendAdditive
 	};
 
-	struct Texture {
-		Common::String name;
-		uint16 width;
-		uint16 height;
-		Common::Array<uint32> pixels;
-	};
-
 	bool loadTextures(const Common::Array<byte> &data);
-	const Texture *findTexture(const char *name) const;
+	const RA2PSXTexture *findTexture(const char *name) const;
 	void drawTexture(Graphics::Surface &surface, const char *name,
 			int x, int y, const Common::Rect &source, int brightness = 0x80,
 			BlendMode blend = kBlendOpaque) const;
 	void drawShield(Graphics::Surface &surface, int shield, int xOffset, int yOffset) const;
 
-	Common::Array<Texture> _textures;
+	Common::Array<RA2PSXTexture> _textures;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index 7fc8bde06ec..b191cbe5805 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -1075,8 +1075,6 @@ Common::KeymapArray ScummMetaEngine::initKeymaps(const char *target) const {
 
 	Common::KeymapArray keymaps = MetaEngine::initKeymaps(target);
 	Common::String gameId = ConfMan.get("gameid", target);
-	const bool isRebel2PSX = gameId == "rebel2" &&
-			parsePlatform(ConfMan.get("platform", target)) == kPlatformPSX;
 	Action *act;
 
 	if (gameId == "rebel1" || gameId == "rebel2") {
@@ -1252,6 +1250,7 @@ Common::KeymapArray ScummMetaEngine::initKeymaps(const char *target) const {
 	}
 
 	if (gameId == "rebel2") {
+		const bool isRebel2PSX = parsePlatform(ConfMan.get("platform", target)) == kPlatformPSX;
 		Keymap *rebel2Keymap = new Keymap(Keymap::kKeymapTypeGame, "scumm-rebel2", _("Rebel Assault II controls"));
 
 		act = new Action("RA2UP", _("Aim up / menu up"));


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

Commit Message:
SCUMM: RA2: render all the triangles for the psx release

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


diff --git a/engines/scumm/insane/rebel2/psx/model.cpp b/engines/scumm/insane/rebel2/psx/model.cpp
index 942ca1df5b1..1ff211f14b6 100644
--- a/engines/scumm/insane/rebel2/psx/model.cpp
+++ b/engines/scumm/insane/rebel2/psx/model.cpp
@@ -10,6 +10,7 @@
  * (at your option) any later version.
  */
 
+#include "common/algorithm.h"
 #include "common/endian.h"
 #include "common/system.h"
 #include "common/util.h"
@@ -482,14 +483,7 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 	TinyGL::setContext(_context);
 	if (!depthTest)
 		tglDisable(TGL_DEPTH_TEST);
-	tglMatrixMode(TGL_MODELVIEW);
-	tglPushMatrix();
-	tglTranslatef(x, y, 0.0f);
-	tglRotatef(roll, 0.0f, 0.0f, 1.0f);
-	tglRotatef(pitch, 1.0f, 0.0f, 0.0f);
-	tglRotatef(yaw, 0.0f, 1.0f, 0.0f);
 	const float scale = size / model.radius();
-	tglScalef(scale, -scale, scale);
 	const float pitchAngle = pitch * 0.017453292519943295f;
 	const float yawAngle = yaw * 0.017453292519943295f;
 	const float rollAngle = roll * 0.017453292519943295f;
@@ -505,6 +499,7 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 	struct FacingVertex {
 		float x;
 		float y;
+		float z;
 	};
 	Common::Array<FacingVertex> facingVertices;
 	facingVertices.resize(vertices.size());
@@ -517,22 +512,19 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 		const float pitchY = pitchCosine * modelY - pitchSine * yawZ;
 		facingVertices[i].x = rollCosine * yawX - rollSine * pitchY;
 		facingVertices[i].y = rollSine * yawX + rollCosine * pitchY;
+		facingVertices[i].z = pitchSine * modelY + pitchCosine * yawZ;
 	}
 
 	const Common::Array<RA2PSXFace> &faces = model.faces();
 	for (uint faceIndex = 0; faceIndex < faces.size(); ++faceIndex) {
 		const RA2PSXFace &face = faces[faceIndex];
-		if (!(face.mode & 0x60)) {
-			const FacingVertex &v0 = facingVertices[face.vertex[0]];
-			const FacingVertex &v1 = facingVertices[face.vertex[1]];
-			const FacingVertex &v2 = facingVertices[face.vertex[2]];
-			if (v0.x * (v1.y - v2.y) + v1.x * (v2.y - v0.y) +
-					v2.x * (v0.y - v1.y) <= 0.0f)
-				continue;
-		}
 		const RA2PSXTexture *texture = model.texture(face.texture);
 		setFaceState(model, face);
-		tglBegin(face.vertexCount == 4 ? TGL_QUADS : TGL_TRIANGLES);
+		float faceDepth = 0.0f;
+		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex)
+			faceDepth += facingVertices[face.vertex[vertexIndex]].z;
+		faceDepth = faceDepth * scale / face.vertexCount;
+		tglBegin(face.vertexCount == 4 ? TGL_QUAD_STRIP : TGL_TRIANGLES);
 		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
 			const float normalX = face.normalX[vertexIndex];
 			const float normalY = -face.normalY[vertexIndex];
@@ -547,12 +539,11 @@ void RA2PSXTinyGLRenderer::renderModel(const RA2PSXModel &model, float x, float
 			if (texture)
 				tglTexCoord2f((face.u[vertexIndex] + 0.5f) / texture->width,
 						(face.v[vertexIndex] + 0.5f) / texture->height);
-			const RA2PSXVertex &vertex = vertices[face.vertex[vertexIndex]];
-			tglVertex3f((float)vertex.x, (float)vertex.y, (float)vertex.z);
+			const FacingVertex &vertex = facingVertices[face.vertex[vertexIndex]];
+			tglVertex3f(x + vertex.x * scale, y + vertex.y * scale, faceDepth);
 		}
 		tglEnd();
 	}
-	tglPopMatrix();
 	if (!depthTest)
 		tglEnable(TGL_DEPTH_TEST);
 }
@@ -618,31 +609,44 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 			projected[i].y = centerY + worldY * focalLength / worldZ;
 		}
 	}
+	struct ProjectedFace {
+		uint index;
+		float depth;
 
-	TinyGL::setContext(_context);
-	if (!depthTest)
-		tglDisable(TGL_DEPTH_TEST);
+		bool operator<(const ProjectedFace &other) const {
+			return depth != other.depth ? depth > other.depth : index < other.index;
+		}
+	};
+	Common::Array<ProjectedFace> projectedFaces;
 	const Common::Array<RA2PSXFace> &faces = model.faces();
 	for (uint faceIndex = 0; faceIndex < faces.size(); ++faceIndex) {
 		const RA2PSXFace &face = faces[faceIndex];
+		ProjectedFace projectedFace = { faceIndex, 0.0f };
 		bool visible = true;
-		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex)
-			visible &= projected[face.vertex[vertexIndex]].visible;
-		if (!visible)
-			continue;
-		if (!(face.mode & 0x60)) {
-			const ProjectedVertex &v0 = projected[face.vertex[0]];
-			const ProjectedVertex &v1 = projected[face.vertex[1]];
-			const ProjectedVertex &v2 = projected[face.vertex[2]];
-			if (v0.x * (v1.y - v2.y) + v1.x * (v2.y - v0.y) +
-					v2.x * (v0.y - v1.y) <= 0.0f)
-				continue;
+		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
+			const ProjectedVertex &vertex = projected[face.vertex[vertexIndex]];
+			visible &= vertex.visible;
+			projectedFace.depth += vertex.z;
+		}
+		if (visible) {
+			projectedFace.depth /= face.vertexCount;
+			projectedFaces.push_back(projectedFace);
 		}
+	}
+	Common::sort(projectedFaces.begin(), projectedFaces.end());
+
+	TinyGL::setContext(_context);
+	if (!depthTest)
+		tglDisable(TGL_DEPTH_TEST);
+	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 RA2PSXTexture *texture = model.texture(face.texture);
 		setFaceState(model, face);
 		const float faceDepth = projected[face.vertex[2]].z;
-		tglBegin(face.vertexCount == 4 ? TGL_QUADS : TGL_TRIANGLES);
+		tglBegin(face.vertexCount == 4 ? TGL_QUAD_STRIP : TGL_TRIANGLES);
 		for (uint vertexIndex = 0; vertexIndex < face.vertexCount; ++vertexIndex) {
 			setFaceColor(face, vertexIndex,
 					modelXx * face.normalX[vertexIndex] +
@@ -658,7 +662,7 @@ void RA2PSXTinyGLRenderer::renderPerspectiveModel(const RA2PSXModel &model,
 				tglTexCoord2f((face.u[vertexIndex] + 0.5f) / texture->width,
 						(face.v[vertexIndex] + 0.5f) / texture->height);
 			const ProjectedVertex &vertex = projected[face.vertex[vertexIndex]];
-			tglVertex3f(vertex.x, vertex.y, 0.0f);
+			tglVertex3f(vertex.x, vertex.y, orderingDepth);
 		}
 		tglEnd();
 	}


Commit: 8875e80135a78742f9cdfb526c94b5242a743ef1
    https://github.com/scummvm/scummvm/commit/8875e80135a78742f9cdfb526c94b5242a743ef1
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: improved sound support for psx release

Changed paths:
  A engines/scumm/insane/rebel2/psx/audio.cpp
    engines/scumm/insane/rebel2/psx/level1.cpp
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/resource.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
new file mode 100644
index 00000000000..17872212995
--- /dev/null
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -0,0 +1,679 @@
+/* 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 "audio/audiostream.h"
+#include "audio/decoders/xa.h"
+#include "audio/mixer.h"
+
+#include "common/endian.h"
+#include "common/memstream.h"
+#include "common/system.h"
+#include "common/util.h"
+
+#include "scumm/scumm_v7.h"
+#include "scumm/insane/rebel2/psx/psx.h"
+
+namespace Scumm {
+
+namespace {
+
+class RA2PSXADSRStream : public Audio::RewindableAudioStream {
+public:
+	RA2PSXADSRStream(Audio::RewindableAudioStream *stream, uint16 attack,
+			uint16 decay, uint16 sustain)
+			: _stream(stream), _position(0) {
+		const uint32 rate = _stream->getRate();
+		_attackSamples = ((uint64)attack * rate + 999) / 1000;
+		_decaySamples = ((uint64)decay * rate + 999) / 1000;
+		_sustain = MIN<uint32>(sustain, 0x1000) * 0x10000 / 0x1000;
+	}
+
+	~RA2PSXADSRStream() override { delete _stream; }
+
+	int readBuffer(int16 *buffer, const int numSamples) override {
+		const int count = _stream->readBuffer(buffer, numSamples);
+		if (count <= 0)
+			return count;
+
+		for (int i = 0; i < count; ++i, ++_position) {
+			uint32 gain;
+			if (_attackSamples && _position < _attackSamples) {
+				gain = (uint32)(_position * 0x10000 / _attackSamples);
+			} else if (_decaySamples && _position < _attackSamples + _decaySamples) {
+				const uint64 decayPosition = _position - _attackSamples;
+				gain = 0x10000 - (uint32)(decayPosition * (0x10000 - _sustain) /
+						_decaySamples);
+			} else {
+				gain = _sustain;
+			}
+			buffer[i] = (int16)((int64)buffer[i] * gain / 0x10000);
+		}
+		return count;
+	}
+
+	bool isStereo() const override { return _stream->isStereo(); }
+	bool endOfData() const override { return _stream->endOfData(); }
+	int getRate() const override { return _stream->getRate(); }
+
+	bool rewind() override {
+		_position = 0;
+		return _stream->rewind();
+	}
+
+private:
+	Audio::RewindableAudioStream *_stream;
+	uint64 _position;
+	uint64 _attackSamples;
+	uint64 _decaySamples;
+	uint32 _sustain;
+};
+
+static 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) {
+	return (int32)(now - target) >= 0;
+}
+
+static int soundBalance(int pan) {
+	return CLIP((pan - 64) * 2, -127, 127);
+}
+
+} // End of anonymous namespace
+
+bool RA2PSXSoundBank::load(const Common::Array<byte> &sampleData,
+		const Common::Array<byte> &projectData) {
+	Common::Array<Sample> samples;
+	Common::Array<SFX> sfx;
+	Common::Array<Macro> macros;
+	Common::Array<ADSR> adsrs;
+
+	if (sampleData.size() < 12 || projectData.size() < 32 ||
+			!matchesTag(projectData, 0, "PROJ"))
+		return false;
+
+	const uint32 directorySize = READ_LE_UINT32(sampleData.data());
+	if (!directorySize || directorySize % 12 || directorySize > sampleData.size())
+		return false;
+
+	for (uint32 offset = 0; offset < directorySize; offset += 12) {
+		Sample sample;
+		sample.offset = READ_LE_UINT32(sampleData.data() + offset);
+		sample.id = READ_LE_UINT16(sampleData.data() + offset + 4);
+		sample.blocks = READ_LE_UINT16(sampleData.data() + offset + 6);
+		sample.rate = READ_LE_UINT16(sampleData.data() + offset + 8);
+		const uint64 end = (uint64)sample.offset + (uint64)sample.blocks * 16;
+		if (sample.offset < directorySize || !sample.blocks || !sample.rate ||
+				end > sampleData.size())
+			return false;
+		samples.push_back(sample);
+	}
+
+	uint32 macroTag = 0xffffffff;
+	uint32 sfxTag = 0xffffffff;
+	for (uint32 offset = 0; offset + 4 <= projectData.size(); ++offset) {
+		if (macroTag == 0xffffffff && matchesTag(projectData, offset, "MACR"))
+			macroTag = offset;
+		if (matchesTag(projectData, offset, "SFXG"))
+			sfxTag = offset;
+	}
+	if (macroTag == 0xffffffff || macroTag + 16 > projectData.size() ||
+			sfxTag == 0xffffffff || sfxTag + 24 > projectData.size())
+		return false;
+
+	const uint32 macroSection = READ_LE_UINT32(projectData.data() + macroTag + 12);
+	if (macroSection > projectData.size() - 20)
+		return false;
+	const uint32 macroPointer = macroSection + 16;
+	const uint64 macroBase64 = (uint64)macroPointer +
+			READ_LE_UINT32(projectData.data() + macroPointer);
+	if (macroBase64 < 4 || macroBase64 > projectData.size())
+		return false;
+	const uint32 macroBase = (uint32)macroBase64;
+	for (uint32 record = macroBase - 4; record + 8 <= projectData.size() &&
+			macros.size() < 256; record += 8) {
+		const int32 relative = (int32)READ_LE_UINT32(projectData.data() + record + 4);
+		const int64 target = (int64)macroBase + relative;
+		if (target < 0 || target + 8 > (int64)projectData.size() || (target & 3))
+			break;
+		Macro macro;
+		macro.id = READ_LE_UINT32(projectData.data() + record);
+		macro.offset = (uint32)target;
+		macros.push_back(macro);
+	}
+	if (macros.empty())
+		return false;
+
+	const uint32 sfxCount = READ_LE_UINT32(projectData.data() + sfxTag + 20);
+	if (!sfxCount || sfxCount > 256 ||
+			(uint64)sfxTag + 24 + (uint64)sfxCount * 10 > projectData.size())
+		return false;
+	for (uint32 i = 0; i < sfxCount; ++i) {
+		const uint32 record = sfxTag + 24 + i * 10;
+		SFX entry;
+		entry.id = READ_LE_UINT16(projectData.data() + record);
+		entry.macro = READ_LE_UINT16(projectData.data() + record + 2);
+		entry.priority = projectData[record + 4];
+		entry.maxVoices = projectData[record + 5];
+		sfx.push_back(entry);
+	}
+
+	uint32 tableEnd = 0xffffffff;
+	if (projectData.size() >= 8) {
+		for (uint32 offset = projectData.size() - 8;; offset -= 4) {
+			if (READ_LE_UINT32(projectData.data() + offset) == 0xffffffff &&
+					READ_LE_UINT32(projectData.data() + offset + 4) == 0xffffffff) {
+				tableEnd = offset;
+				break;
+			}
+			if (offset < 4)
+				break;
+		}
+	}
+	if (tableEnd == 0xffffffff)
+		return false;
+
+	uint32 tableStart = tableEnd;
+	int32 expectedRelative = -8;
+	while (tableStart >= 8) {
+		const uint32 record = tableStart - 8;
+		const int32 relative = (int32)READ_LE_UINT32(projectData.data() + record);
+		const uint32 id = READ_LE_UINT32(projectData.data() + record + 4);
+		if (relative != expectedRelative || id > 0xffff)
+			break;
+		tableStart = record;
+		expectedRelative -= 8;
+	}
+	for (uint32 record = tableStart; record < tableEnd; record += 8) {
+		const int32 relative = (int32)READ_LE_UINT32(projectData.data() + record);
+		const int64 target = (int64)tableStart + relative;
+		if (target < 0 || target + 8 > tableStart)
+			return false;
+		ADSR adsr;
+		adsr.id = (uint16)READ_LE_UINT32(projectData.data() + record + 4);
+		adsr.attack = READ_LE_UINT16(projectData.data() + target);
+		adsr.decay = READ_LE_UINT16(projectData.data() + target + 2);
+		adsr.sustain = READ_LE_UINT16(projectData.data() + target + 4);
+		adsr.release = READ_LE_UINT16(projectData.data() + target + 6);
+		adsrs.push_back(adsr);
+	}
+	if (adsrs.empty())
+		return false;
+
+	_data = sampleData;
+	_projectData = projectData;
+	_samples = samples;
+	_sfx = sfx;
+	_macros = macros;
+	_adsrs = adsrs;
+	return true;
+}
+
+const RA2PSXSoundBank::Sample *RA2PSXSoundBank::findSample(uint16 id) const {
+	for (uint i = 0; i < _samples.size(); ++i) {
+		if (_samples[i].id == id)
+			return &_samples[i];
+	}
+	return nullptr;
+}
+
+const RA2PSXSoundBank::ADSR *RA2PSXSoundBank::findADSR(uint16 id) const {
+	for (uint i = 0; i < _adsrs.size(); ++i) {
+		if (_adsrs[i].id == id)
+			return &_adsrs[i];
+	}
+	return nullptr;
+}
+
+bool RA2PSXSoundBank::getSFX(uint16 id, uint16 &macro, byte &priority,
+		byte &maxVoices) const {
+	for (uint i = 0; i < _sfx.size(); ++i) {
+		if (_sfx[i].id != id)
+			continue;
+		macro = _sfx[i].macro;
+		priority = _sfx[i].priority;
+		maxVoices = _sfx[i].maxVoices;
+		return true;
+	}
+	return false;
+}
+
+bool RA2PSXSoundBank::getMacroCommand(uint16 macro, uint16 step, byte *command) const {
+	for (uint i = 0; i < _macros.size(); ++i) {
+		if (_macros[i].id != macro)
+			continue;
+		const uint64 offset = (uint64)_macros[i].offset + (uint64)step * 8;
+		if (offset + 8 > _projectData.size())
+			return false;
+		memcpy(command, _projectData.data() + offset, 8);
+		return true;
+	}
+	return false;
+}
+
+Audio::RewindableAudioStream *RA2PSXSoundBank::makeStream(uint16 id, uint32 rate,
+		uint16 adsrId) const {
+	const Sample *sample = findSample(id);
+	if (!sample)
+		return nullptr;
+
+	const uint32 size = (uint32)sample->blocks * 16;
+	byte *copy = (byte *)malloc(size);
+	if (!copy)
+		return nullptr;
+	memcpy(copy, _data.data() + sample->offset, size);
+
+	Common::SeekableReadStream *source =
+			new Common::MemoryReadStream(copy, size, DisposeAfterUse::YES);
+	Audio::RewindableAudioStream *stream =
+			Audio::makeXAStream(source, rate ? rate : sample->rate);
+	const ADSR *adsr = findADSR(adsrId);
+	if (adsr)
+		stream = new RA2PSXADSRStream(stream, adsr->attack, adsr->decay, adsr->sustain);
+	return stream;
+}
+
+struct RA2PSXSoundPlayer::Impl {
+	enum {
+		kVoiceCount = 24,
+		kGroupCount = 64,
+		kMacroTicksPerSecond = 60,
+		kVoiceStartupTicks = 4
+	};
+
+	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),
+				startedAt(0), waitUntil(0) {}
+
+		bool active;
+		bool waiting;
+		bool waitForSampleEnd;
+		bool macroDone;
+		bool root;
+		SoundId sound;
+		uint16 macro;
+		uint16 step;
+		uint16 adsr;
+		uint32 rate;
+		int rateOverride;
+		int volume;
+		int pan;
+		byte priority;
+		uint32 born;
+		uint32 readyTick;
+		uint32 startedAt;
+		uint32 waitUntil;
+		Audio::SoundHandle handle;
+	};
+
+	struct Group {
+		Group() : active(false), hasExpiry(false), sound(0), born(0), expiry(0) {}
+
+		bool active;
+		bool hasExpiry;
+		SoundId sound;
+		uint32 born;
+		uint32 expiry;
+	};
+
+	Impl(ScummEngine_v7 *engine, const RA2PSXSoundBank &soundBank)
+			: vm(engine), bank(soundBank), nextSound(1), tick(1), serial(1),
+			nextUpdate(0), tickRemainder(0), randomState(0x13579bdf) {}
+
+	ScummEngine_v7 *vm;
+	const RA2PSXSoundBank &bank;
+	Voice voices[kVoiceCount];
+	Group groups[kGroupCount];
+	SoundId nextSound;
+	uint32 tick;
+	uint32 serial;
+	uint32 nextUpdate;
+	uint32 tickRemainder;
+	uint32 randomState;
+
+	int findGroup(SoundId sound) const {
+		for (int i = 0; i < kGroupCount; ++i) {
+			if (groups[i].active && groups[i].sound == sound)
+				return i;
+		}
+		return -1;
+	}
+
+	void clearVoice(int index) {
+		if (voices[index].active)
+			vm->_mixer->stopHandle(voices[index].handle);
+		voices[index] = Voice();
+	}
+
+	void stopGroup(int index) {
+		const SoundId sound = groups[index].sound;
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (voices[i].active && voices[i].sound == sound)
+				clearVoice(i);
+		}
+		groups[index] = Group();
+	}
+
+	int allocateGroup(SoundId sound) {
+		int slot = -1;
+		for (int i = 0; i < kGroupCount; ++i) {
+			if (!groups[i].active) {
+				slot = i;
+				break;
+			}
+		}
+		if (slot < 0) {
+			slot = 0;
+			for (int i = 1; i < kGroupCount; ++i) {
+				if (groups[i].born < groups[slot].born)
+					slot = i;
+			}
+			stopGroup(slot);
+		}
+		groups[slot].active = true;
+		groups[slot].sound = sound;
+		groups[slot].born = serial++;
+		return slot;
+	}
+
+	int allocateVoice(uint16 macro, byte priority, byte maxVoices, int excluded) {
+		int sameMacro = 0;
+		int oldestSame = -1;
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (!voices[i].active || voices[i].macro != macro || i == excluded)
+				continue;
+			++sameMacro;
+			if (oldestSame < 0 || voices[i].born < voices[oldestSame].born)
+				oldestSame = i;
+		}
+		if (maxVoices < kVoiceCount && sameMacro >= maxVoices) {
+			if (oldestSame < 0)
+				return -1;
+			clearVoice(oldestSame);
+			return oldestSame;
+		}
+
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (!voices[i].active)
+				return i;
+		}
+
+		int victim = -1;
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (i == excluded || voices[i].priority > priority)
+				continue;
+			if (victim < 0 || voices[i].priority < voices[victim].priority ||
+					(voices[i].priority == voices[victim].priority &&
+					 voices[i].born < voices[victim].born))
+				victim = i;
+		}
+		if (victim >= 0)
+			clearVoice(victim);
+		return victim;
+	}
+
+	int startVoice(SoundId sound, uint16 macro, uint16 step, byte priority,
+			byte maxVoices, int volume, int pan, int rateOverride, bool root,
+			uint32 now, int excluded) {
+		const int slot = allocateVoice(macro, priority, maxVoices, excluded);
+		if (slot < 0)
+			return -1;
+
+		Voice &voice = voices[slot];
+		voice.active = true;
+		voice.root = root;
+		voice.sound = sound;
+		voice.macro = macro;
+		voice.step = step;
+		voice.rateOverride = rateOverride;
+		voice.volume = CLIP(volume, 0, 127);
+		voice.pan = CLIP(pan, 0, 127);
+		voice.priority = priority;
+		voice.born = serial++;
+		voice.readyTick = tick + kVoiceStartupTicks;
+		voice.startedAt = now;
+		return slot;
+	}
+
+	void setGroupExpiry(SoundId sound, uint32 expiry) {
+		const int group = findGroup(sound);
+		if (group >= 0) {
+			groups[group].hasExpiry = true;
+			groups[group].expiry = expiry;
+		}
+	}
+
+	bool nextCommandEnds(const Voice &voice) const {
+		byte command[8];
+		return bank.getMacroCommand(voice.macro, voice.step, command) &&
+				((command[0] & 0x3f) == 0 || (command[0] & 0x3f) == 1);
+	}
+
+	void finishVoice(int index) {
+		const SoundId sound = voices[index].sound;
+		if (voices[index].root) {
+			const int group = findGroup(sound);
+			if (group >= 0)
+				stopGroup(group);
+			return;
+		}
+		voices[index].macroDone = true;
+		if (!vm->_mixer->isSoundHandleActive(voices[index].handle))
+			clearVoice(index);
+	}
+
+	void runVoice(int index, uint32 now) {
+		for (int commandCount = 0; commandCount < 64; ++commandCount) {
+			if (!voices[index].active)
+				return;
+			Voice &voice = voices[index];
+			byte command[8];
+			if (!bank.getMacroCommand(voice.macro, voice.step++, command)) {
+				finishVoice(index);
+				return;
+			}
+
+			switch (command[0] & 0x3f) {
+			case 0:
+			case 1:
+				finishVoice(index);
+				return;
+			case 7: {
+				uint32 milliseconds = READ_LE_UINT16(command + 6);
+				if (command[2] && milliseconds) {
+					randomState = randomState * 1103515245 + 12345;
+					milliseconds = randomState % milliseconds;
+				}
+				voice.waitUntil = command[4] ? voice.startedAt + milliseconds : now + milliseconds;
+				voice.waitForSampleEnd = command[3] != 0;
+				voice.waiting = milliseconds != 0;
+				if (voice.root && nextCommandEnds(voice))
+					setGroupExpiry(voice.sound, voice.waitUntil);
+				if (voice.waiting)
+					return;
+				break;
+			}
+			case 8: {
+				const uint16 macro = READ_LE_UINT16(command + 2);
+				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);
+				}
+				break;
+			}
+			case 0xc:
+				voice.adsr = READ_LE_UINT16(command + 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));
+					}
+				}
+				break;
+			}
+			case 0x1f:
+				voice.rate = (uint32)command[1] | ((uint32)command[2] << 8) |
+						((uint32)command[3] << 16);
+				break;
+			default:
+				break;
+			}
+		}
+		finishVoice(index);
+	}
+
+	SoundId play(uint16 sfxId, int volume, int pan, int rate) {
+		uint16 macro;
+		byte priority;
+		byte maxVoices;
+		if (!bank.getSFX(sfxId, macro, priority, maxVoices))
+			return kInvalidSoundId;
+
+		SoundId sound = nextSound++;
+		if (sound == kInvalidSoundId)
+			sound = nextSound++;
+		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);
+		if (voice < 0) {
+			groups[group] = Group();
+			return kInvalidSoundId;
+		}
+		return sound;
+	}
+
+	void update() {
+		const uint32 now = g_system->getMillis();
+		if (nextUpdate && !timeReached(now, nextUpdate))
+			return;
+		uint32 tickMillis = 1000 / kMacroTicksPerSecond;
+		tickRemainder += 1000 % kMacroTicksPerSecond;
+		if (tickRemainder >= kMacroTicksPerSecond) {
+			++tickMillis;
+			tickRemainder -= kMacroTicksPerSecond;
+		}
+		nextUpdate = now + tickMillis;
+		++tick;
+
+		for (int i = 0; i < kGroupCount; ++i) {
+			if (groups[i].active && groups[i].hasExpiry && timeReached(now, groups[i].expiry))
+				stopGroup(i);
+		}
+
+		for (int i = 0; i < kVoiceCount; ++i) {
+			Voice &voice = voices[i];
+			if (!voice.active || voice.readyTick > tick)
+				continue;
+			if (voice.macroDone) {
+				if (!vm->_mixer->isSoundHandleActive(voice.handle))
+					clearVoice(i);
+				continue;
+			}
+			if (voice.waiting) {
+				const bool sampleEnded = voice.waitForSampleEnd &&
+						!vm->_mixer->isSoundHandleActive(voice.handle);
+				if (!sampleEnded && !timeReached(now, voice.waitUntil))
+					continue;
+				voice.waiting = false;
+			}
+			runVoice(i, now);
+		}
+
+		for (int i = 0; i < kGroupCount; ++i) {
+			if (!groups[i].active || groups[i].hasExpiry)
+				continue;
+			bool hasVoice = false;
+			for (int j = 0; j < kVoiceCount; ++j)
+				hasVoice |= voices[j].active && voices[j].sound == groups[i].sound;
+			if (!hasVoice)
+				groups[i] = Group();
+		}
+	}
+
+	void setPan(SoundId sound, int pan) {
+		pan = CLIP(pan, 0, 127);
+		for (int i = 0; i < kVoiceCount; ++i) {
+			if (!voices[i].active || voices[i].sound != sound)
+				continue;
+			voices[i].pan = pan;
+			if (vm->_mixer->isSoundHandleActive(voices[i].handle))
+				vm->_mixer->setChannelBalance(voices[i].handle, soundBalance(pan));
+		}
+	}
+
+	void stop(SoundId sound) {
+		const int group = findGroup(sound);
+		if (group >= 0)
+			stopGroup(group);
+	}
+
+	void stopAll() {
+		for (int i = 0; i < kGroupCount; ++i) {
+			if (groups[i].active)
+				stopGroup(i);
+		}
+	}
+};
+
+RA2PSXSoundPlayer::RA2PSXSoundPlayer(ScummEngine_v7 *vm, const RA2PSXSoundBank &bank)
+		: _impl(new Impl(vm, bank)) {
+}
+
+RA2PSXSoundPlayer::~RA2PSXSoundPlayer() {
+	_impl->stopAll();
+	delete _impl;
+}
+
+RA2PSXSoundPlayer::SoundId RA2PSXSoundPlayer::play(uint16 sfx, int volume,
+		int pan, int rate) {
+	return _impl->play(sfx, volume, pan, rate);
+}
+
+void RA2PSXSoundPlayer::update() {
+	_impl->update();
+}
+
+void RA2PSXSoundPlayer::setPan(SoundId sound, int pan) {
+	_impl->setPan(sound, pan);
+}
+
+void RA2PSXSoundPlayer::stop(SoundId sound) {
+	_impl->stop(sound);
+}
+
+void RA2PSXSoundPlayer::stopAll() {
+	_impl->stopAll();
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index ff7fe44fda6..c98d58bc80b 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -39,6 +39,10 @@ namespace Scumm {
 #ifdef USE_TINYGL
 static const int kLevel1FrameRate = 30;
 
+static int getLevel1SoundPan(float screenX) {
+	return CLIP<int>((int)(screenX * 127.0f / 320.0f), 0, 127);
+}
+
 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),
@@ -381,6 +385,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	RA2PSXLevel1Explosion explosions[3];
 	RA2PSXLevel1Shot shots[8];
 	RA2PSXLevel1Ship ship;
+	RA2PSXSoundPlayer soundPlayer(_vm, _soundBank);
+	RA2PSXSoundPlayer::SoundId approachSounds[3] = {};
 	int aimX = 160;
 	int aimY = 113;
 	int aimVelocityX = 0;
@@ -577,6 +583,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		}
 		if (result == kLevel1Error)
 			break;
+		soundPlayer.update();
 
 		const uint32 elapsed = g_system->getMillis() - gameplayStartTime;
 		const int targetLogicFrame = (int)((uint64)elapsed * kLevel1FrameRate / 1000);
@@ -606,12 +613,19 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				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 (!enemies[i].active) {
 						spawnLevel1Enemy(enemies[i], _vm->_rnd);
+						spawnedEnemy = i;
 						break;
 					}
 				}
+				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);
+				}
 				spawnDelay = spawnBase + _vm->_rnd.getRandomNumber(spawnRange - 1);
 			}
 
@@ -626,15 +640,26 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					--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;
-					if (_vm->_rnd.getRandomNumber(99) < 38)
+					soundPlayer.play(0x17, 0x4e, soundPan);
+					if (_vm->_rnd.getRandomNumber(99) < 38) {
 						shield = MAX(0, shield - (int)_vm->_rnd.getRandomNumberRng(6, 10));
+						soundPlayer.play(0x36, 0x7f, 0x40);
+					}
 				}
 				if (enemies[i].age >= enemies[i].lifetime) {
+					const int rate = _vm->_rnd.getRandomNumber(0x3fff);
+					soundPlayer.play(0x1b, 0x5a, soundPan, rate);
+					soundPlayer.stop(approachSounds[i]);
+					approachSounds[i] = RA2PSXSoundPlayer::kInvalidSoundId;
 					enemies[i].active = false;
-					if (_vm->_rnd.getRandomNumber(99) < 18)
+					if (_vm->_rnd.getRandomNumber(99) < 18) {
 						shield = MAX(0, shield - 12);
+						soundPlayer.play(0x36, 0x7f, 0x40);
+					}
 				}
 			}
 
@@ -648,6 +673,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				if (!spawnLevel1Shot(shots, aimX, aimY,
 						thirdPersonView ? &ship : nullptr, shotTargetX, shotTargetY))
 					continue;
+				soundPlayer.play(0x18, 0x3f, 0x40);
 				lastShotFrame = logicFrame;
 				int hitEnemy = -1;
 				float hitDistance = 1000000.0f;
@@ -664,6 +690,11 @@ 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);
+					soundPlayer.stop(approachSounds[hitEnemy]);
+					approachSounds[hitEnemy] = RA2PSXSoundPlayer::kInvalidSoundId;
 					enemies[hitEnemy].active = false;
 					score = MIN(9999999, score + 100);
 					for (int i = 0; i < 3; ++i) {
@@ -700,11 +731,8 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 				renderer.renderPerspectiveModel(shipModel, ship.x, ship.y, ship.z,
 						forwardX, forwardY, forwardZ, shipRoll, false);
 			} else {
-				const float crosshairX = (aimX - 160) * 620.0f / 640.0f;
-				const float crosshairY = (aimY - 120) * 620.0f / 640.0f;
 				renderer.renderModel(crosshair, aimX, aimY, 31.0f,
-						crosshairY * 720.0f / 4096.0f,
-						-crosshairX * 720.0f / 4096.0f, 0.0f, false);
+						0.0f, 0.0f, 0.0f, false);
 			}
 			Graphics::Surface output;
 			renderer.finishFrame(output);
@@ -720,6 +748,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 		g_system->delayMillis(5);
 	}
 
+	soundPlayer.stopAll();
 	decoder.close();
 	CursorMan.showMouse(cursorWasVisible);
 	return _vm->shouldQuit() ? kLevel1Quit : result;
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 6b37b003e46..058cedecbfe 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -116,6 +116,19 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 
 bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
+	Common::SeekableReadStream *soundStream = openResource(0);
+	if (!soundStream)
+		return false;
+	RA2PSXArchive soundArchive;
+	const bool soundArchiveLoaded = soundArchive.load(*soundStream);
+	delete soundStream;
+	Common::Array<byte> soundData;
+	Common::Array<byte> soundProjectData;
+	if (!soundArchiveLoaded || !soundArchive.getMember("SNDsmp", soundData) ||
+			!soundArchive.getMember("sNDdata", soundProjectData) ||
+			!_soundBank.load(soundData, soundProjectData))
+		return false;
+
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 060a9a8d908..2dafcc98e50 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -29,6 +29,10 @@ namespace Common {
 class SeekableReadStream;
 }
 
+namespace Audio {
+class RewindableAudioStream;
+}
+
 namespace Scumm {
 
 class ScummEngine_v7;
@@ -57,6 +61,69 @@ private:
 	Common::Array<byte> _data;
 };
 
+class RA2PSXSoundBank {
+public:
+	bool load(const Common::Array<byte> &sampleData, const Common::Array<byte> &projectData);
+	Audio::RewindableAudioStream *makeStream(uint16 id, uint32 rate,
+			uint16 adsrId = 0xffff) const;
+	bool getSFX(uint16 id, uint16 &macro, byte &priority, byte &maxVoices) const;
+	bool getMacroCommand(uint16 macro, uint16 step, byte *command) const;
+
+private:
+	struct Sample {
+		uint32 offset;
+		uint16 id;
+		uint16 blocks;
+		uint16 rate;
+	};
+	struct SFX {
+		uint16 id;
+		uint16 macro;
+		byte priority;
+		byte maxVoices;
+	};
+	struct Macro {
+		uint32 id;
+		uint32 offset;
+	};
+	struct ADSR {
+		uint16 id;
+		uint16 attack;
+		uint16 decay;
+		uint16 sustain;
+		uint16 release;
+	};
+
+	const Sample *findSample(uint16 id) const;
+	const ADSR *findADSR(uint16 id) const;
+
+	Common::Array<byte> _data;
+	Common::Array<byte> _projectData;
+	Common::Array<Sample> _samples;
+	Common::Array<SFX> _sfx;
+	Common::Array<Macro> _macros;
+	Common::Array<ADSR> _adsrs;
+};
+
+class RA2PSXSoundPlayer {
+public:
+	typedef uint32 SoundId;
+	enum { kInvalidSoundId = 0 };
+
+	RA2PSXSoundPlayer(ScummEngine_v7 *vm, const RA2PSXSoundBank &bank);
+	~RA2PSXSoundPlayer();
+
+	SoundId play(uint16 sfx, int volume, int pan, int rate = -1);
+	void update();
+	void setPan(SoundId sound, int pan);
+	void stop(SoundId sound);
+	void stopAll();
+
+private:
+	struct Impl;
+	Impl *_impl;
+};
+
 struct RA2PSXVertex {
 	int16 x;
 	int16 y;
@@ -170,6 +237,7 @@ private:
 			const RA2PSXLevel1UI &ui, int lives, int &score);
 
 	ScummEngine_v7 *_vm;
+	RA2PSXSoundBank _soundBank;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/resource.cpp b/engines/scumm/insane/rebel2/psx/resource.cpp
index 4c397da44ba..a9c07b1d96e 100644
--- a/engines/scumm/insane/rebel2/psx/resource.cpp
+++ b/engines/scumm/insane/rebel2/psx/resource.cpp
@@ -101,7 +101,9 @@ bool RA2PSXArchive::decompress(const byte *source, uint32 sourceSize, uint32 exp
 
 bool RA2PSXArchive::unpack(const Entry &entry, Common::Array<byte> &data) const {
 	if (!entry.unpackedSize) {
-		data.clear();
+		data.resize(entry.endOffset - entry.offset);
+		if (!data.empty())
+			memcpy(data.data(), _data.data() + entry.offset, data.size());
 		return true;
 	}
 	return decompress(_data.data() + entry.offset, entry.endOffset - entry.offset,
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index c91e53f3756..a4ca3f905c7 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -179,6 +179,7 @@ MODULE_OBJS += \
 
 ifdef ENABLE_REBEL2_PSX
 MODULE_OBJS += \
+	insane/rebel2/psx/audio.o \
 	insane/rebel2/psx/level1.o \
 	insane/rebel2/psx/model.o \
 	insane/rebel2/psx/psx.o \


Commit: 4b8f11a32ae830fb08a9e8939906933ce4f4c363
    https://github.com/scummvm/scummvm/commit/4b8f11a32ae830fb08a9e8939906933ce4f4c363
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: added initial support for main meny in the psx release

Changed paths:
  A engines/scumm/insane/rebel2/psx/menu.cpp
  A engines/scumm/insane/rebel2/shared.h
    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/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/psx/ui.cpp
    engines/scumm/insane/rebel2/psx/ui.h
    engines/scumm/insane/rebel2/rebel.cpp
    engines/scumm/metaengine.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/rebel2/menu.cpp b/engines/scumm/insane/rebel2/menu.cpp
index de13ab5a26d..51bbee2f9cd 100644
--- a/engines/scumm/insane/rebel2/menu.cpp
+++ b/engines/scumm/insane/rebel2/menu.cpp
@@ -37,6 +37,7 @@
 #include "scumm/smush/rebel/smush_multi_font.h"
 
 #include "scumm/insane/rebel2/rebel.h"
+#include "scumm/insane/rebel2/shared.h"
 
 namespace Scumm {
 
@@ -120,41 +121,14 @@ int InsaneRebel2::processMenuInput() {
 		switch (event.type) {
 		case Common::EVENT_KEYDOWN:
 			_menuInactivityTimer = 0;
-
-			switch (event.kbd.keycode) {
-			case Common::KEYCODE_UP:
-				_menuSelection--;
-				if (_menuSelection < 0) {
-					_menuSelection = _menuItemCount - 1;
-				}
-				_menuRepeatDelay = 3;
-				debugC(DEBUG_INSANE, "Menu: Selection changed to %d (UP)", _menuSelection);
-				break;
-
-			case Common::KEYCODE_DOWN:
-				_menuSelection++;
-				if (_menuSelection >= _menuItemCount) {
-					_menuSelection = 0;
-				}
-				_menuRepeatDelay = 3;
-				debugC(DEBUG_INSANE, "Menu: Selection changed to %d (DOWN)", _menuSelection);
-				break;
-
-			case Common::KEYCODE_RETURN:
-			case Common::KEYCODE_KP_ENTER:
-				if (_menuSelection >= 0 && _menuSelection < _menuItemCount) {
-					result = _menuSelection;
-					debugC(DEBUG_INSANE, "Menu: Item %d selected (ENTER)", _menuSelection);
-				}
-				break;
-
-			case Common::KEYCODE_ESCAPE:
-				result = _menuItemCount - 1;
-				debugC(DEBUG_INSANE, "Menu: Back action - selecting quit (item %d)", result);
-				break;
-
-			default:
-				break;
+			{
+				const Rebel2MenuCommand command = getRebel2MenuCommand(event.kbd);
+				const int oldSelection = _menuSelection;
+				result = applyRebel2MenuCommand(command, _menuItemCount, _menuSelection);
+				if (result == kRebel2MenuResultCancel)
+					result = _menuItemCount - 1;
+				if (_menuSelection != oldSelection)
+					_menuRepeatDelay = 3;
 			}
 			break;
 
diff --git a/engines/scumm/insane/rebel2/psx/audio.cpp b/engines/scumm/insane/rebel2/psx/audio.cpp
index 17872212995..4f99c1904ea 100644
--- a/engines/scumm/insane/rebel2/psx/audio.cpp
+++ b/engines/scumm/insane/rebel2/psx/audio.cpp
@@ -32,8 +32,6 @@
 
 namespace Scumm {
 
-namespace {
-
 class RA2PSXADSRStream : public Audio::RewindableAudioStream {
 public:
 	RA2PSXADSRStream(Audio::RewindableAudioStream *stream, uint16 attack,
@@ -97,8 +95,6 @@ static int soundBalance(int pan) {
 	return CLIP((pan - 64) * 2, -127, 127);
 }
 
-} // End of anonymous namespace
-
 bool RA2PSXSoundBank::load(const Common::Array<byte> &sampleData,
 		const Common::Array<byte> &projectData) {
 	Common::Array<Sample> samples;
diff --git a/engines/scumm/insane/rebel2/psx/level1.cpp b/engines/scumm/insane/rebel2/psx/level1.cpp
index c98d58bc80b..729dfd9d5b0 100644
--- a/engines/scumm/insane/rebel2/psx/level1.cpp
+++ b/engines/scumm/insane/rebel2/psx/level1.cpp
@@ -28,6 +28,7 @@
 #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"
 #include "scumm/insane/rebel2/psx/video.h"
@@ -400,7 +401,7 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	int nextSpawnAdjustment = 0;
 	int logicFrame = -1;
 	int videoFrame = -1;
-	int lastShotFrame = -4;
+	int16 rapidFireCounter = 0;
 	bool moveLeft = false;
 	bool moveRight = false;
 	bool moveUp = false;
@@ -415,10 +416,13 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 	bool keyFire = false;
 	bool actionFire = false;
 	bool fireRequested = false;
+	bool fireWasPressed = false;
 	bool thirdPersonView = true;
 	Level1Result result = kLevel1Complete;
 	const int joystickDeadzone = MIN<int>(Common::JOYAXIS_MAX,
 			MAX(0, ConfMan.getInt("joystick_deadzone")) * 1000);
+	const bool rapidFire = ConfMan.hasKey("rebel2_rapid_fire") ?
+			ConfMan.getBool("rebel2_rapid_fire") : true;
 
 	const bool cursorWasVisible = CursorMan.isVisible();
 	CursorMan.showMouse(false);
@@ -475,8 +479,9 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 					moveDown = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_SPACE ||
 						event.kbd.keycode == Common::KEYCODE_RETURN) {
+					if (!keyFire && !event.kbdRepeat)
+						fireRequested = true;
 					keyFire = true;
-					fireRequested = true;
 				}
 				break;
 			case Common::EVENT_KEYUP:
@@ -664,17 +669,18 @@ Rebel2PSX::Level1Result Rebel2PSX::playLevel1(const RA2PSXModel &enemyModel,
 			}
 
 			const bool heldFire = mouseFire || keyFire || actionFire;
-			const bool shootRequested = fireRequested ||
-					(heldFire && logicFrame - lastShotFrame >= 12);
+			const bool triggerShot = updateRebel2Fire(heldFire, fireWasPressed,
+					rapidFire, false, rapidFireCounter);
+			fireWasPressed = heldFire;
+			const bool shootRequested = fireRequested || triggerShot;
 			fireRequested = false;
-			if (shootRequested && logicFrame - lastShotFrame >= 4) {
+			if (shootRequested) {
 				int shotTargetX;
 				int shotTargetY;
 				if (!spawnLevel1Shot(shots, aimX, aimY,
 						thirdPersonView ? &ship : nullptr, shotTargetX, shotTargetY))
 					continue;
 				soundPlayer.play(0x18, 0x3f, 0x40);
-				lastShotFrame = logicFrame;
 				int hitEnemy = -1;
 				float hitDistance = 1000000.0f;
 				for (int i = 0; i < 3; ++i) {
diff --git a/engines/scumm/insane/rebel2/psx/menu.cpp b/engines/scumm/insane/rebel2/psx/menu.cpp
new file mode 100644
index 00000000000..0bbf2d2fbc4
--- /dev/null
+++ b/engines/scumm/insane/rebel2/psx/menu.cpp
@@ -0,0 +1,155 @@
+/* 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 "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"
+#include "scumm/insane/rebel2/psx/ui.h"
+
+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;
+	}
+}
+
+Rebel2PSX::MenuResult Rebel2PSX::runMainMenu(const RA2PSXMainMenuUI &ui) {
+	Graphics::Surface surface;
+	surface.create(_vm->_screenWidth, _vm->_screenHeight, g_system->getScreenFormat());
+
+	const bool cursorWasVisible = CursorMan.isVisible();
+	CursorMan.showMouse(true);
+	int selection = 0;
+	MenuResult result = kMenuQuit;
+	bool redraw = true;
+
+	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;
+			}
+
+			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;
+					}
+					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;
+		}
+
+		if (_vm->shouldQuit())
+			break;
+		if (result == kMenuStart)
+			break;
+		if (openGlobalMenu) {
+			_vm->openMainMenuDialog();
+			redraw = true;
+			continue;
+		}
+		if (openOptions) {
+			GUI::ConfigDialog dialog;
+			dialog.runModal();
+			g_system->applyBackendSettings();
+			_vm->syncSoundSettings();
+			redraw = true;
+			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;
+		}
+		g_system->delayMillis(10);
+	}
+
+	surface.free();
+	CursorMan.showMouse(cursorWasVisible);
+	return _vm->shouldQuit() ? kMenuQuit : result;
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 058cedecbfe..9a2b7b284d9 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -24,6 +24,7 @@
 #include "common/translation.h"
 #include "common/util.h"
 
+#include "graphics/cursorman.h"
 #include "graphics/surface.h"
 
 #include "scumm/scumm_v7.h"
@@ -77,6 +78,8 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 		return false;
 	}
 	decoder.start();
+	const bool cursorWasVisible = CursorMan.isVisible();
+	CursorMan.showMouse(false);
 	g_system->fillScreen(0);
 
 	bool skipped = false;
@@ -111,24 +114,41 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 		g_system->delayMillis(5);
 	}
 	decoder.close();
+	CursorMan.showMouse(cursorWasVisible);
 	return !_vm->shouldQuit();
 }
 
-bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
-		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
-	Common::SeekableReadStream *soundStream = openResource(0);
-	if (!soundStream)
+bool Rebel2PSX::playIntroSequence() {
+	static const char *const videos[] = {
+		"LEC_LOGO.STR",
+		"F5_LOGO.STR",
+		"OPENING.STR"
+	};
+	for (uint i = 0; i < ARRAYSIZE(videos); ++i) {
+		if (!playVideo(videos[i], 1, false))
+			return false;
+	}
+	return true;
+}
+
+bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
+	Common::SeekableReadStream *stream = openResource(0);
+	if (!stream)
 		return false;
-	RA2PSXArchive soundArchive;
-	const bool soundArchiveLoaded = soundArchive.load(*soundStream);
-	delete soundStream;
+
+	RA2PSXArchive archive;
+	const bool loaded = archive.load(*stream);
+	delete stream;
 	Common::Array<byte> soundData;
 	Common::Array<byte> soundProjectData;
-	if (!soundArchiveLoaded || !soundArchive.getMember("SNDsmp", soundData) ||
-			!soundArchive.getMember("sNDdata", soundProjectData) ||
-			!_soundBank.load(soundData, soundProjectData))
-		return false;
+	return loaded && menu.load(archive) &&
+			archive.getMember("SNDsmp", soundData) &&
+			archive.getMember("sNDdata", soundProjectData) &&
+			_soundBank.load(soundData, soundProjectData);
+}
 
+bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
+		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
 	if (!stream)
 		return false;
@@ -155,6 +175,21 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 
 Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
+	RA2PSXMainMenuUI menu;
+	if (!loadGlobalAssets(menu))
+		return Common::Error(Common::kReadingFailed,
+				_("Could not load the PlayStation menu resources"));
+	if (!playIntroSequence()) {
+		if (_vm->shouldQuit())
+			return Common::kNoError;
+		return Common::Error(Common::kReadingFailed,
+				_("Could not play the PlayStation introduction"));
+	}
+
+	const MenuResult menuResult = runMainMenu(menu);
+	if (menuResult == kMenuQuit)
+		return Common::kNoError;
+
 	RA2PSXModel enemy;
 	RA2PSXModel ship;
 	RA2PSXModel crosshair;
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 2dafcc98e50..2bae0b86328 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 RA2PSXMainMenuUI;
 
 class RA2PSXArchive {
 public:
@@ -220,6 +221,11 @@ public:
 	Common::Error runGame();
 
 private:
+	enum MenuResult {
+		kMenuStart,
+		kMenuQuit
+	};
+
 	enum Level1Result {
 		kLevel1Quit,
 		kLevel1Complete,
@@ -230,6 +236,9 @@ private:
 	Common::SeekableReadStream *openResource(int number);
 	Common::SeekableReadStream *openRawFile(const Common::Path &path, int discNumber);
 	bool playVideo(const Common::Path &path, int discNumber, bool version2);
+	bool playIntroSequence();
+	MenuResult runMainMenu(const RA2PSXMainMenuUI &ui);
+	bool loadGlobalAssets(RA2PSXMainMenuUI &menu);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui);
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index cb8066f8b0a..f3a2e16238a 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -47,11 +47,11 @@ static RA2PSXUIColor shieldColor(const RA2PSXUIGradientStop *stops, uint count,
 	return stops[count - 1].color;
 }
 
-bool RA2PSXLevel1UI::loadTextures(const Common::Array<byte> &data) {
+bool RA2PSXTextureSet::append(const Common::Array<byte> &data) {
 	return loadRA2PSXTextures(data, _textures);
 }
 
-const RA2PSXTexture *RA2PSXLevel1UI::findTexture(const char *name) const {
+const RA2PSXTexture *RA2PSXTextureSet::find(const char *name) const {
 	for (uint i = 0; i < _textures.size(); ++i) {
 		if (_textures[i].name.equalsIgnoreCase(name))
 			return &_textures[i];
@@ -59,28 +59,9 @@ const RA2PSXTexture *RA2PSXLevel1UI::findTexture(const char *name) const {
 	return nullptr;
 }
 
-bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
-	Common::Array<byte> data;
-	_textures.clear();
-	if (!archive.getMember("tex/Common", data) || !loadTextures(data))
-		return false;
-	if (!archive.getMember("tex/BWingCockp", data) || !loadTextures(data))
-		return false;
-
-	static const char *const required[] = {
-		"COCKPITL", "COCKPITR", "PANEL1", "PANEL2", "PANEL3", "STATTEXT",
-		"FONT8X9", "REBLSIGN", "ENRGYMSK", "SMALLEX"
-	};
-	for (uint i = 0; i < ARRAYSIZE(required); ++i) {
-		if (!findTexture(required[i]))
-			return false;
-	}
-	return true;
-}
-
-void RA2PSXLevel1UI::drawTexture(Graphics::Surface &surface, const char *name,
+void RA2PSXTextureSet::draw(Graphics::Surface &surface, const char *name,
 		int x, int y, const Common::Rect &source, int brightness, BlendMode blend) const {
-	const RA2PSXTexture *texture = findTexture(name);
+	const RA2PSXTexture *texture = find(name);
 	if (!texture)
 		return;
 
@@ -130,17 +111,99 @@ void RA2PSXLevel1UI::drawTexture(Graphics::Surface &surface, const char *name,
 	}
 }
 
+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");
+
+	for (; *text; ++text) {
+		int glyph = -1;
+		for (uint i = 0; i < ARRAYSIZE(widths); ++i) {
+			if (*text == glyphs[i]) {
+				glyph = i;
+				break;
+			}
+		}
+		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;
+	}
+}
+
+bool RA2PSXMainMenuUI::load(const RA2PSXArchive &archive) {
+	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");
+}
+
+void RA2PSXMainMenuUI::draw(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));
+
+	static const char *const items[] = { "start", "options" };
+	static const int itemX[] = { 141, 134 };
+	for (uint i = 0; i < ARRAYSIZE(items); ++i) {
+		_textures.drawText(surface, i == (uint)selection ? "STD_FT4" : "STD_FT6",
+				items[i], xOffset + itemX[i], yOffset + 166 + i * 10);
+	}
+	_textures.drawText(surface, "STD_FT2", "developed by factor 5",
+			xOffset + 80, yOffset + 202);
+	_textures.drawText(surface, "STD_FT2", "C 1996 lucasarts entertainment company",
+			xOffset + 16, yOffset + 212);
+}
+
+Common::Rect RA2PSXMainMenuUI::itemRect(int item) const {
+	return Common::Rect(120, 164 + item * 10, 200, 174 + item * 10);
+}
+
+bool RA2PSXLevel1UI::load(const RA2PSXArchive &archive) {
+	Common::Array<byte> data;
+	_textures.clear();
+	if (!archive.getMember("tex/Common", data) || !_textures.append(data))
+		return false;
+	if (!archive.getMember("tex/BWingCockp", data) || !_textures.append(data))
+		return false;
+
+	static const char *const required[] = {
+		"COCKPITL", "COCKPITR", "PANEL1", "PANEL2", "PANEL3", "STATTEXT",
+		"FONT8X9", "REBLSIGN", "ENRGYMSK", "SMALLEX"
+	};
+	for (uint i = 0; i < ARRAYSIZE(required); ++i) {
+		if (!_textures.has(required[i]))
+			return false;
+	}
+	return true;
+}
+
 void RA2PSXLevel1UI::drawCockpit(Graphics::Surface &surface) const {
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2 + 120;
-	drawTexture(surface, "COCKPITL", xOffset, yOffset, Common::Rect(0, 0, 224, 120));
-	drawTexture(surface, "COCKPITR", xOffset + 224, yOffset, Common::Rect(0, 0, 120, 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));
 }
 
 void RA2PSXLevel1UI::drawExplosion(Graphics::Surface &surface, int x, int y, int frame) const {
 	const int stage = CLIP<int>(frame / 2, 0, 4);
-	drawTexture(surface, "SMALLEX", x - 8, y - 8,
-			Common::Rect(stage * 16, 0, stage * 16 + 16, 16), 0x80, kBlendAdditive);
+	_textures.draw(surface, "SMALLEX", x - 8, y - 8,
+			Common::Rect(stage * 16, 0, stage * 16 + 16, 16), 0x80,
+			RA2PSXTextureSet::kBlendAdditive);
 }
 
 void RA2PSXLevel1UI::drawShield(Graphics::Surface &surface, int shield,
@@ -185,8 +248,9 @@ void RA2PSXLevel1UI::drawShield(Graphics::Surface &surface, int shield,
 
 		const int sourceLeft = 77 - width - row;
 		if (sourceLeft < 68)
-			drawTexture(surface, "ENRGYMSK", left, y,
-					Common::Rect(MAX(0, sourceLeft), row, 68, row + 1), 0x50, kBlendAdditive);
+			_textures.draw(surface, "ENRGYMSK", left, y,
+					Common::Rect(MAX(0, sourceLeft), row, 68, row + 1), 0x50,
+					RA2PSXTextureSet::kBlendAdditive);
 	}
 }
 
@@ -195,37 +259,37 @@ void RA2PSXLevel1UI::drawHUD(Graphics::Surface &surface, int score, int lives,
 	const int xOffset = (surface.w - 320) / 2;
 	const int yOffset = (surface.h - 240) / 2;
 
-	drawTexture(surface, "PANEL3", xOffset + 2, yOffset + 17, Common::Rect(0, 0, 126, 21));
-	drawTexture(surface, "PANEL2", xOffset + 262, yOffset + 17, Common::Rect(0, 0, 56, 29));
-	drawTexture(surface, "PANEL1", xOffset + 210, yOffset + 207, Common::Rect(0, 0, 108, 21));
+	_textures.draw(surface, "PANEL3", xOffset + 2, yOffset + 17, Common::Rect(0, 0, 126, 21));
+	_textures.draw(surface, "PANEL2", xOffset + 262, yOffset + 17, Common::Rect(0, 0, 56, 29));
+	_textures.draw(surface, "PANEL1", xOffset + 210, yOffset + 207, Common::Rect(0, 0, 108, 21));
 	drawShield(surface, shield, xOffset, yOffset);
 
-	drawTexture(surface, "STATTEXT", xOffset + 14, yOffset + 21,
+	_textures.draw(surface, "STATTEXT", xOffset + 14, yOffset + 21,
 			Common::Rect(42, 0, 62, 13), 0x5a);
 	score = CLIP<int>(score, 0, 9999999);
 	int divisor = 1000000;
 	for (int digit = 0; digit < 7; ++digit) {
 		const int value = score / divisor % 10;
-		drawTexture(surface, "FONT8X9", xOffset + 39 + digit * 10, yOffset + 23,
+		_textures.draw(surface, "FONT8X9", xOffset + 39 + digit * 10, yOffset + 23,
 				Common::Rect(value * 8, 0, value * 8 + 8, 9));
 		divisor /= 10;
 	}
 
 	const int rebelFrame = (MAX(frame, 0) / 2) % 12;
-	drawTexture(surface, "REBLSIGN", xOffset + 292, yOffset + 22,
+	_textures.draw(surface, "REBLSIGN", xOffset + 292, yOffset + 22,
 			Common::Rect((rebelFrame % 6) * 20, (rebelFrame / 6) * 19,
 					(rebelFrame % 6 + 1) * 20, (rebelFrame / 6 + 1) * 19));
 	const int reserveLives = CLIP<int>(lives - 1, 0, 9);
-	drawTexture(surface, "FONT8X9", xOffset + 274, yOffset + 28,
+	_textures.draw(surface, "FONT8X9", xOffset + 274, yOffset + 28,
 			Common::Rect(reserveLives * 8, 0, reserveLives * 8 + 8, 9));
-	drawTexture(surface, "FONT8X9", xOffset + 283, yOffset + 29, Common::Rect(80, 0, 88, 9));
+	_textures.draw(surface, "FONT8X9", xOffset + 283, yOffset + 29, Common::Rect(80, 0, 88, 9));
 
 	int shieldLabelBrightness = 0x5a;
 	if (shield <= 31) {
 		const int phase = (MAX(frame, 0) / 2) % 14;
 		shieldLabelBrightness += phase < 7 ? -50 + phase * 10 : 20 - (phase - 7) * 10;
 	}
-	drawTexture(surface, "STATTEXT", xOffset + 291, yOffset + 211,
+	_textures.draw(surface, "STATTEXT", xOffset + 291, yOffset + 211,
 			Common::Rect(0, 0, 20, 13), shieldLabelBrightness);
 }
 
diff --git a/engines/scumm/insane/rebel2/psx/ui.h b/engines/scumm/insane/rebel2/psx/ui.h
index 6665d5875cd..2e9ce8e6a1d 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -21,6 +21,37 @@
 
 namespace Scumm {
 
+class RA2PSXTextureSet {
+public:
+	enum BlendMode {
+		kBlendOpaque,
+		kBlendAdditive
+	};
+
+	void clear() { _textures.clear(); }
+	bool append(const Common::Array<byte> &data);
+	bool has(const char *name) const { return find(name) != nullptr; }
+	void draw(Graphics::Surface &surface, const char *name, int x, int y,
+			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;
+
+private:
+	const RA2PSXTexture *find(const char *name) const;
+	Common::Array<RA2PSXTexture> _textures;
+};
+
+class RA2PSXMainMenuUI {
+public:
+	bool load(const RA2PSXArchive &archive);
+	void draw(Graphics::Surface &surface, int selection) const;
+	Common::Rect itemRect(int item) const;
+
+private:
+	RA2PSXTextureSet _textures;
+};
+
 class RA2PSXLevel1UI {
 public:
 	bool load(const RA2PSXArchive &archive);
@@ -30,19 +61,9 @@ public:
 	void drawHUD(Graphics::Surface &surface, int score, int lives, int shield, int frame) const;
 
 private:
-	enum BlendMode {
-		kBlendOpaque,
-		kBlendAdditive
-	};
-
-	bool loadTextures(const Common::Array<byte> &data);
-	const RA2PSXTexture *findTexture(const char *name) const;
-	void drawTexture(Graphics::Surface &surface, const char *name,
-			int x, int y, const Common::Rect &source, int brightness = 0x80,
-			BlendMode blend = kBlendOpaque) const;
 	void drawShield(Graphics::Surface &surface, int shield, int xOffset, int yOffset) const;
 
-	Common::Array<RA2PSXTexture> _textures;
+	RA2PSXTextureSet _textures;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel2/rebel.cpp b/engines/scumm/insane/rebel2/rebel.cpp
index 79315879380..882da9df8fc 100644
--- a/engines/scumm/insane/rebel2/rebel.cpp
+++ b/engines/scumm/insane/rebel2/rebel.cpp
@@ -44,6 +44,7 @@
 #include "scumm/smush/rebel/font_rebel2.h"
 
 #include "scumm/insane/rebel2/rebel.h"
+#include "scumm/insane/rebel2/shared.h"
 
 #include "common/config-manager.h"
 #include "audio/audiostream.h"
@@ -1516,7 +1517,7 @@ int32 InsaneRebel2::processMouse() {
 
 	bool leftPressed = (currentButtons & 1) != 0;
 	bool leftWasPressed = (_prevMouseButtons & 1) != 0;
-	bool leftEdge = leftPressed && !leftWasPressed;
+	const bool leftEdge = leftPressed && !leftWasPressed;
 	bool rightPressed = (currentButtons & 2) != 0;
 	bool rightWasPressed = (_prevMouseButtons & 2) != 0;
 
@@ -1548,16 +1549,8 @@ int32 InsaneRebel2::processMouse() {
 	if (autoFire)
 		_rebelControlMode |= 1;
 
-	// Rapid fire injects a held-button shot every 5th frame from the press.
-	if (leftEdge)
-		_rapidFireCounter = 0;
-	bool rapidFireShot = false;
-	if (!_rebelAutoPlay && _optRapidFire) {
-		rapidFireShot = (_rapidFireCounter % 5 == 0) && leftPressed;
-		_rapidFireCounter++;
-	}
-
-	bool triggerShot = leftEdge || rapidFireShot || autoFire;
+	const bool triggerShot = updateRebel2Fire(leftPressed, leftWasPressed,
+			!_rebelAutoPlay && _optRapidFire, autoFire, _rapidFireCounter);
 	if (_rebelHandler == 8) {
 		_shipFiring = triggerShot && canShoot;
 	}
diff --git a/engines/scumm/insane/rebel2/shared.h b/engines/scumm/insane/rebel2/shared.h
new file mode 100644
index 00000000000..3476a8c40de
--- /dev/null
+++ b/engines/scumm/insane/rebel2/shared.h
@@ -0,0 +1,89 @@
+/* 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.
+ */
+
+#ifndef SCUMM_INSANE_REBEL2_SHARED_H
+#define SCUMM_INSANE_REBEL2_SHARED_H
+
+#include "common/keyboard.h"
+
+namespace Scumm {
+
+enum Rebel2MenuCommand {
+	kRebel2MenuCommandNone,
+	kRebel2MenuCommandUp,
+	kRebel2MenuCommandDown,
+	kRebel2MenuCommandAccept,
+	kRebel2MenuCommandCancel
+};
+
+enum {
+	kRebel2MenuResultCancel = -2,
+	kRebel2MenuResultNone = -1
+};
+
+inline Rebel2MenuCommand getRebel2MenuCommand(const Common::KeyState &key) {
+	switch (key.keycode) {
+	case Common::KEYCODE_UP:
+		return kRebel2MenuCommandUp;
+	case Common::KEYCODE_DOWN:
+		return kRebel2MenuCommandDown;
+	case Common::KEYCODE_RETURN:
+	case Common::KEYCODE_KP_ENTER:
+		return kRebel2MenuCommandAccept;
+	case Common::KEYCODE_ESCAPE:
+		return kRebel2MenuCommandCancel;
+	default:
+		return kRebel2MenuCommandNone;
+	}
+}
+
+inline int applyRebel2MenuCommand(Rebel2MenuCommand command, int itemCount,
+		int &selection) {
+	if (itemCount <= 0)
+		return kRebel2MenuResultNone;
+
+	switch (command) {
+	case kRebel2MenuCommandUp:
+		if (--selection < 0)
+			selection = itemCount - 1;
+		break;
+	case kRebel2MenuCommandDown:
+		if (++selection >= itemCount)
+			selection = 0;
+		break;
+	case kRebel2MenuCommandAccept:
+		return selection >= 0 && selection < itemCount ? selection : kRebel2MenuResultNone;
+	case kRebel2MenuCommandCancel:
+		return kRebel2MenuResultCancel;
+	default:
+		break;
+	}
+	return kRebel2MenuResultNone;
+}
+
+inline bool updateRebel2Fire(bool pressed, bool wasPressed,
+		bool rapidFire, bool autoFire, int16 &rapidFireCounter) {
+	const bool pressedEdge = pressed && !wasPressed;
+	if (pressedEdge)
+		rapidFireCounter = 0;
+
+	bool rapidFireShot = false;
+	if (rapidFire) {
+		rapidFireShot = pressed && rapidFireCounter % 5 == 0;
+		rapidFireCounter = (rapidFireCounter + 1) % 5;
+	}
+	return pressedEdge || rapidFireShot || autoFire;
+}
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index b191cbe5805..d69986ea5e0 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -238,6 +238,11 @@ bool ScummMetaEngine::hasFeature(MetaEngineFeature f) const {
 }
 
 bool ScummEngine::hasFeature(EngineFeature f) const {
+#ifdef ENABLE_REBEL2_PSX
+	if (_game.id == GID_REBEL2 && _game.platform == Common::kPlatformPSX &&
+			(f == kSupportsLoadingDuringRuntime || f == kSupportsSavingDuringRuntime))
+		return false;
+#endif
 	return
 		(f == kSupportsReturnToLauncher) ||
 		(f == kSupportsLoadingDuringRuntime) ||
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index a4ca3f905c7..93edc2ba909 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/menu.o \
 	insane/rebel2/psx/model.o \
 	insane/rebel2/psx/psx.o \
 	insane/rebel2/psx/resource.o \


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

Commit Message:
PLAYGROUND3D: Added a quad strip test

Changed paths:
    engines/playground3d/gfx.h
    engines/playground3d/gfx_opengl.cpp
    engines/playground3d/gfx_opengl.h
    engines/playground3d/gfx_opengl_shaders.cpp
    engines/playground3d/gfx_opengl_shaders.h
    engines/playground3d/gfx_tinygl.cpp
    engines/playground3d/gfx_tinygl.h
    engines/playground3d/playground3d.cpp


diff --git a/engines/playground3d/gfx.h b/engines/playground3d/gfx.h
index 606b68c96aa..f518836057d 100644
--- a/engines/playground3d/gfx.h
+++ b/engines/playground3d/gfx.h
@@ -76,6 +76,7 @@ public:
 	virtual void loadTextureRGBA4444(Graphics::Surface *texture) = 0;
 	virtual void drawCube(const Math::Vector3d &pos, const Math::Vector3d &roll) = 0;
 	virtual void drawPolyOffsetTest(const Math::Vector3d &pos, const Math::Vector3d &roll) = 0;
+	virtual void drawQuadStripTest() = 0;
 	virtual void dimRegionInOut(float fade) = 0;
 	virtual void drawInViewport() = 0;
 	virtual void drawRgbaTexture() = 0;
diff --git a/engines/playground3d/gfx_opengl.cpp b/engines/playground3d/gfx_opengl.cpp
index b84e2f61da7..60b4dffa96e 100644
--- a/engines/playground3d/gfx_opengl.cpp
+++ b/engines/playground3d/gfx_opengl.cpp
@@ -230,6 +230,46 @@ void OpenGLRenderer::drawPolyOffsetTest(const Math::Vector3d &pos, const Math::V
 	glDisable(GL_POLYGON_OFFSET_FILL);
 }
 
+void OpenGLRenderer::drawQuadStripTest() {
+	glMatrixMode(GL_PROJECTION);
+	glPushMatrix();
+	glLoadIdentity();
+
+	glMatrixMode(GL_MODELVIEW);
+	glPushMatrix();
+	glLoadIdentity();
+
+	glDisable(GL_BLEND);
+	glDisable(GL_CULL_FACE);
+	glDisable(GL_DEPTH_TEST);
+	glDepthMask(GL_FALSE);
+	glDisable(GL_TEXTURE_2D);
+
+	glColor4f(0.0f, 0.75f, 0.2f, 1.0f);
+	glBegin(GL_QUAD_STRIP);
+	glVertex3f(-0.8f,  0.7f, 0.0f);
+	glVertex3f( 0.8f,  0.7f, 0.0f);
+	glVertex3f(-0.8f, -0.7f, 0.0f);
+	glVertex3f( 0.8f, -0.7f, 0.0f);
+	glEnd();
+
+	glColor4f(1.0f, 0.0f, 0.0f, 1.0f);
+	glBegin(GL_TRIANGLES);
+	glVertex3f(-0.12f,  0.12f, 0.0f);
+	glVertex3f( 0.12f,  0.12f, 0.0f);
+	glVertex3f( 0.0f, -0.12f, 0.0f);
+	glEnd();
+
+	glDepthMask(GL_TRUE);
+	glEnable(GL_DEPTH_TEST);
+
+	glMatrixMode(GL_MODELVIEW);
+	glPopMatrix();
+
+	glMatrixMode(GL_PROJECTION);
+	glPopMatrix();
+}
+
 void OpenGLRenderer::dimRegionInOut(float fade) {
 	glMatrixMode(GL_PROJECTION);
 	glPushMatrix();
diff --git a/engines/playground3d/gfx_opengl.h b/engines/playground3d/gfx_opengl.h
index 97328af21b1..9b35c203ccd 100644
--- a/engines/playground3d/gfx_opengl.h
+++ b/engines/playground3d/gfx_opengl.h
@@ -55,6 +55,7 @@ public:
 	void setupViewport(int x, int y, int width, int height) override;
 	void drawCube(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
 	void drawPolyOffsetTest(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
+	void drawQuadStripTest() override;
 	void dimRegionInOut(float fade) override;
 	void drawInViewport() override;
 	void drawRgbaTexture() override;
diff --git a/engines/playground3d/gfx_opengl_shaders.cpp b/engines/playground3d/gfx_opengl_shaders.cpp
index 79cc37cc9cf..0fa196f0160 100644
--- a/engines/playground3d/gfx_opengl_shaders.cpp
+++ b/engines/playground3d/gfx_opengl_shaders.cpp
@@ -71,6 +71,14 @@ static const GLfloat boxVertices[] = {
 	 0.1f,  0.1f,
 	-0.1f, -0.1f,
 	 0.1f, -0.1f,
+	// quad strip test
+	-0.8f,   0.7f,
+	 0.8f,   0.7f,
+	-0.8f,  -0.7f,
+	 0.8f,  -0.7f,
+	-0.12f,  0.12f,
+	 0.12f,  0.12f,
+	 0.0f,  -0.12f,
 };
 
 static const GLfloat bitmapVertices[] = {
@@ -283,6 +291,24 @@ void ShaderRenderer::drawPolyOffsetTest(const Math::Vector3d &pos, const Math::V
 	glDisable(GL_POLYGON_OFFSET_FILL);
 }
 
+void ShaderRenderer::drawQuadStripTest() {
+	glDisable(GL_BLEND);
+	glDisable(GL_CULL_FACE);
+	glDisable(GL_DEPTH_TEST);
+	glDepthMask(GL_FALSE);
+
+	_viewportShader->use();
+	_viewportShader->setUniform("offset", Math::Vector2d(0.0f, 0.0f));
+	_viewportShader->setUniform("color", Math::Vector3d(0.0f, 0.75f, 0.2f));
+	glDrawArrays(GL_TRIANGLE_STRIP, 8, 4);
+	_viewportShader->setUniform("color", Math::Vector3d(1.0f, 0.0f, 0.0f));
+	glDrawArrays(GL_TRIANGLES, 12, 3);
+	_viewportShader->unbind();
+
+	glDepthMask(GL_TRUE);
+	glEnable(GL_DEPTH_TEST);
+}
+
 void ShaderRenderer::dimRegionInOut(float fade) {
 	glEnable(GL_BLEND);
 	glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
diff --git a/engines/playground3d/gfx_opengl_shaders.h b/engines/playground3d/gfx_opengl_shaders.h
index e6a606206e6..dd877d8a7bb 100644
--- a/engines/playground3d/gfx_opengl_shaders.h
+++ b/engines/playground3d/gfx_opengl_shaders.h
@@ -55,6 +55,7 @@ public:
 	void setupViewport(int x, int y, int width, int height) override;
 	void drawCube(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
 	void drawPolyOffsetTest(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
+	void drawQuadStripTest() override;
 	void dimRegionInOut(float fade) override;
 	void drawInViewport() override;
 	void drawRgbaTexture() override;
diff --git a/engines/playground3d/gfx_tinygl.cpp b/engines/playground3d/gfx_tinygl.cpp
index 1e5f7f997d9..f15e8fef910 100644
--- a/engines/playground3d/gfx_tinygl.cpp
+++ b/engines/playground3d/gfx_tinygl.cpp
@@ -266,6 +266,46 @@ void TinyGLRenderer::drawPolyOffsetTest(const Math::Vector3d &pos, const Math::V
 	tglDisable(TGL_POLYGON_OFFSET_FILL);
 }
 
+void TinyGLRenderer::drawQuadStripTest() {
+	tglMatrixMode(TGL_PROJECTION);
+	tglPushMatrix();
+	tglLoadIdentity();
+
+	tglMatrixMode(TGL_MODELVIEW);
+	tglPushMatrix();
+	tglLoadIdentity();
+
+	tglDisable(TGL_BLEND);
+	tglDisable(TGL_CULL_FACE);
+	tglDisable(TGL_DEPTH_TEST);
+	tglDepthMask(TGL_FALSE);
+	tglDisable(TGL_TEXTURE_2D);
+
+	tglColor4f(0.0f, 0.75f, 0.2f, 1.0f);
+	tglBegin(TGL_QUAD_STRIP);
+	tglVertex3f(-0.8f,  0.7f, 0.0f);
+	tglVertex3f( 0.8f,  0.7f, 0.0f);
+	tglVertex3f(-0.8f, -0.7f, 0.0f);
+	tglVertex3f( 0.8f, -0.7f, 0.0f);
+	tglEnd();
+
+	tglColor4f(1.0f, 0.0f, 0.0f, 1.0f);
+	tglBegin(TGL_TRIANGLES);
+	tglVertex3f(-0.12f,  0.12f, 0.0f);
+	tglVertex3f( 0.12f,  0.12f, 0.0f);
+	tglVertex3f( 0.0f, -0.12f, 0.0f);
+	tglEnd();
+
+	tglDepthMask(TGL_TRUE);
+	tglEnable(TGL_DEPTH_TEST);
+
+	tglMatrixMode(TGL_MODELVIEW);
+	tglPopMatrix();
+
+	tglMatrixMode(TGL_PROJECTION);
+	tglPopMatrix();
+}
+
 void TinyGLRenderer::flipBuffer() {
 	Common::List<Common::Rect> dirtyAreas;
 	TinyGL::presentBuffer(dirtyAreas);
diff --git a/engines/playground3d/gfx_tinygl.h b/engines/playground3d/gfx_tinygl.h
index 489c5ca6e90..406317b1ea9 100644
--- a/engines/playground3d/gfx_tinygl.h
+++ b/engines/playground3d/gfx_tinygl.h
@@ -50,6 +50,7 @@ public:
 	void setupViewport(int x, int y, int width, int height) override;
 	void drawCube(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
 	void drawPolyOffsetTest(const Math::Vector3d &pos, const Math::Vector3d &roll) override;
+	void drawQuadStripTest() override;
 	void dimRegionInOut(float fade) override;
 	void drawInViewport() override;
 	void drawRgbaTexture() override;
diff --git a/engines/playground3d/playground3d.cpp b/engines/playground3d/playground3d.cpp
index e18a30a611e..b8db73c2844 100644
--- a/engines/playground3d/playground3d.cpp
+++ b/engines/playground3d/playground3d.cpp
@@ -109,6 +109,7 @@ Common::Error Playground3dEngine::run() {
 	// 3 - fade in/out
 	// 4 - moving filled rectangle in viewport
 	// 5 - drawing RGBA pattern texture to check endian correctness
+	// 6 - quad strip followed by a triangle
 	_testId = 1;
 	_fogEnable = false;
 	_scissorEnable = false;
@@ -138,6 +139,9 @@ Common::Error Playground3dEngine::run() {
 			}
 			break;
 		}
+		case 6:
+			_clearColor = Math::Vector4d(0.25f, 0.25f, 0.25f, 1.0f);
+			break;
 		default:
 			assert(false);
 	}
@@ -167,7 +171,7 @@ void Playground3dEngine::processInput() {
 		switch (event.customType) {
 		case kActionSwitchTest:
 			_testId++;
-			if (_testId > 5)
+			if (_testId > 6)
 				_testId = 1;
 			switch (_testId) {
 				case 1:
@@ -190,6 +194,9 @@ void Playground3dEngine::processInput() {
 					}
 					break;
 				}
+				case 6:
+					_clearColor = Math::Vector4d(0.25f, 0.25f, 0.25f, 1.0f);
+					break;
 				default:
 					assert(false);
 			}
@@ -313,6 +320,9 @@ void Playground3dEngine::drawFrame() {
 			_gfx->loadTextureRGBA4444(_rgba4444Texture);
 			drawRgbaTexture();
 			break;
+		case 6:
+			_gfx->drawQuadStripTest();
+			break;
 		default:
 			assert(false);
 	}


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

Commit Message:
TINYGL: Fix deferred quad strip rendering

Changed paths:
    graphics/tinygl/zdirtyrect.cpp


diff --git a/graphics/tinygl/zdirtyrect.cpp b/graphics/tinygl/zdirtyrect.cpp
index 6af86ae35a6..b8016326205 100644
--- a/graphics/tinygl/zdirtyrect.cpp
+++ b/graphics/tinygl/zdirtyrect.cpp
@@ -362,7 +362,6 @@ void RasterizationDrawCall::execute(bool restoreState, const Common::Rect *clipp
 	c->draw_triangle_front = (gl_draw_triangle_func)_drawTriangleFront;
 	c->draw_triangle_back = (gl_draw_triangle_func)_drawTriangleBack;
 
-	int n = c->vertex_n;
 	int cnt = c->vertex_cnt;
 
 	switch (c->begin_type) {
@@ -419,12 +418,9 @@ void RasterizationDrawCall::execute(bool restoreState, const Common::Rect *clipp
 		}
 		break;
 	case TGL_QUAD_STRIP:
-		for( ; n >= 4; n -= 2) {
-			c->gl_draw_triangle(&c->vertex[0], &c->vertex[1], &c->vertex[2]);
-			c->gl_draw_triangle(&c->vertex[1], &c->vertex[3], &c->vertex[2]);
-			for (int i = 0; i < 2; i++) {
-				c->vertex[i] = c->vertex[i + 2];
-			}
+		for (int i = 0; i + 3 < cnt; i += 2) {
+			c->gl_draw_triangle(&c->vertex[i], &c->vertex[i + 1], &c->vertex[i + 2]);
+			c->gl_draw_triangle(&c->vertex[i + 1], &c->vertex[i + 3], &c->vertex[i + 2]);
 		}
 		break;
 	case TGL_POLYGON: {


Commit: 91bd24ea249f225944b9facc108569449d8e2267
    https://github.com/scummvm/scummvm/commit/91bd24ea249f225944b9facc108569449d8e2267
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: font rendering support for psx release

Changed paths:
    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/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 9a2b7b284d9..82465dbe4b0 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -65,7 +65,8 @@ Common::SeekableReadStream *Rebel2PSX::openRawFile(const Common::Path &path, int
 	return file;
 }
 
-bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version2) {
+bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version2,
+		const RA2PSXMovieText *movieText, RA2PSXMovieTextSequence textSequence) {
 	Common::SeekableReadStream *stream = openRawFile(path, discNumber);
 	if (!stream)
 		return false;
@@ -95,6 +96,13 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 				const int destY = (_vm->_screenHeight - height) / 2;
 				g_system->copyRectToScreen(frame->getBasePtr(sourceX, sourceY), frame->pitch,
 						destX, destY, width, height);
+				if (movieText && textSequence != kRA2PSXMovieTextNone) {
+					Graphics::Surface *screen = g_system->lockScreen();
+					// The original callback advances the STR's one-based frame ID.
+					movieText->draw(*screen, textSequence, decoder.getCurFrame() + 2,
+							(_vm->_screenWidth - 320) / 2, (_vm->_screenHeight - 240) / 2);
+					g_system->unlockScreen();
+				}
 				g_system->updateScreen();
 			}
 		}
@@ -118,14 +126,16 @@ bool Rebel2PSX::playVideo(const Common::Path &path, int discNumber, bool version
 	return !_vm->shouldQuit();
 }
 
-bool Rebel2PSX::playIntroSequence() {
+bool Rebel2PSX::playIntroSequence(const RA2PSXMovieText &movieText) {
 	static const char *const videos[] = {
 		"LEC_LOGO.STR",
 		"F5_LOGO.STR",
 		"OPENING.STR"
 	};
 	for (uint i = 0; i < ARRAYSIZE(videos); ++i) {
-		if (!playVideo(videos[i], 1, false))
+		const RA2PSXMovieTextSequence textSequence = i == 2 ?
+				kRA2PSXMovieTextOpening : kRA2PSXMovieTextNone;
+		if (!playVideo(videos[i], 1, false, &movieText, textSequence))
 			return false;
 	}
 	return true;
@@ -147,6 +157,11 @@ bool Rebel2PSX::loadGlobalAssets(RA2PSXMainMenuUI &menu) {
 			_soundBank.load(soundData, soundProjectData);
 }
 
+bool Rebel2PSX::loadMovieTextAssets(RA2PSXMovieText &movieText) {
+	Common::File executable;
+	return executable.open("SLUS_003.81") && movieText.load(executable);
+}
+
 bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 		RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui) {
 	Common::SeekableReadStream *stream = openResource(1);
@@ -176,10 +191,14 @@ bool Rebel2PSX::loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 Common::Error Rebel2PSX::runGame() {
 #ifdef USE_TINYGL
 	RA2PSXMainMenuUI menu;
+	RA2PSXMovieText movieText;
 	if (!loadGlobalAssets(menu))
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation menu resources"));
-	if (!playIntroSequence()) {
+	if (!loadMovieTextAssets(movieText))
+		return Common::Error(Common::kReadingFailed,
+				_("Could not load the PlayStation movie fonts"));
+	if (!playIntroSequence(movieText)) {
 		if (_vm->shouldQuit())
 			return Common::kNoError;
 		return Common::Error(Common::kReadingFailed,
@@ -199,7 +218,8 @@ Common::Error Rebel2PSX::runGame() {
 		return Common::Error(Common::kReadingFailed,
 				_("Could not load the PlayStation Level 1 resources"));
 
-	if (!playVideo("S1/L01_INTR.STR", 1, false)) {
+	if (!playVideo("S1/L01_INTR.STR", 1, false, &movieText,
+			kRA2PSXMovieTextChapter1)) {
 		if (_vm->shouldQuit())
 			return Common::kNoError;
 		return Common::Error(Common::kReadingFailed,
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index 2bae0b86328..fb6ad53ccb5 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -38,6 +38,13 @@ namespace Scumm {
 class ScummEngine_v7;
 class RA2PSXLevel1UI;
 class RA2PSXMainMenuUI;
+class RA2PSXMovieText;
+
+enum RA2PSXMovieTextSequence {
+	kRA2PSXMovieTextNone,
+	kRA2PSXMovieTextOpening,
+	kRA2PSXMovieTextChapter1
+};
 
 class RA2PSXArchive {
 public:
@@ -235,10 +242,13 @@ private:
 
 	Common::SeekableReadStream *openResource(int number);
 	Common::SeekableReadStream *openRawFile(const Common::Path &path, int discNumber);
-	bool playVideo(const Common::Path &path, int discNumber, bool version2);
-	bool playIntroSequence();
+	bool playVideo(const Common::Path &path, int discNumber, bool version2,
+			const RA2PSXMovieText *movieText = nullptr,
+			RA2PSXMovieTextSequence textSequence = kRA2PSXMovieTextNone);
+	bool playIntroSequence(const RA2PSXMovieText &movieText);
 	MenuResult runMainMenu(const RA2PSXMainMenuUI &ui);
 	bool loadGlobalAssets(RA2PSXMainMenuUI &menu);
+	bool loadMovieTextAssets(RA2PSXMovieText &movieText);
 	bool loadLevel1Assets(RA2PSXModel &enemy, RA2PSXModel &ship,
 			RA2PSXModel &crosshair, RA2PSXModel &laser, RA2PSXLevel1UI &ui);
 	Level1Result playLevel1(const RA2PSXModel &enemy, const RA2PSXModel &ship,
diff --git a/engines/scumm/insane/rebel2/psx/ui.cpp b/engines/scumm/insane/rebel2/psx/ui.cpp
index f3a2e16238a..bc25de6c95b 100644
--- a/engines/scumm/insane/rebel2/psx/ui.cpp
+++ b/engines/scumm/insane/rebel2/psx/ui.cpp
@@ -10,6 +10,8 @@
  * (at your option) any later version.
  */
 
+#include "common/memstream.h"
+#include "common/stream.h"
 #include "common/util.h"
 
 #include "scumm/insane/rebel2/psx/ui.h"
@@ -51,6 +53,29 @@ bool RA2PSXTextureSet::append(const Common::Array<byte> &data) {
 	return loadRA2PSXTextures(data, _textures);
 }
 
+bool RA2PSXTextureSet::appendRaw24(const char *name, const Common::Array<byte> &data,
+		uint16 width, uint16 height) {
+	if (!width || !height || data.size() != (uint32)width * height * 3)
+		return false;
+
+	RA2PSXTexture texture;
+	texture.name = name;
+	texture.width = width;
+	texture.height = height;
+	texture.pixels.resize((uint32)width * height);
+	for (uint32 i = 0; i < texture.pixels.size(); ++i) {
+		const byte *pixel = data.data() + i * 3;
+		if (!(pixel[0] | pixel[1] | pixel[2])) {
+			texture.pixels[i] = 0;
+			continue;
+		}
+		texture.pixels[i] = 0x01000000 | ((uint32)pixel[0] << 16) |
+				((uint32)pixel[1] << 8) | pixel[2];
+	}
+	_textures.push_back(texture);
+	return true;
+}
+
 const RA2PSXTexture *RA2PSXTextureSet::find(const char *name) const {
 	for (uint i = 0; i < _textures.size(); ++i) {
 		if (_textures[i].name.equalsIgnoreCase(name))
@@ -141,6 +166,284 @@ void RA2PSXTextureSet::drawText(Graphics::Surface &surface, const char *font,
 	}
 }
 
+struct RA2PSXMovieFont {
+	const char *texture;
+	const char *characters;
+	const byte *widths;
+	const byte *advances;
+	int fixedAdvance;
+	int height;
+	int rowStep;
+};
+
+struct RA2PSXMovieGlyph {
+	int x;
+	int row;
+	int width;
+};
+
+struct RA2PSXMovieTextRecord {
+	uint16 firstFrame;
+	uint16 duration;
+	byte style;
+	byte color;
+	byte initialCharacters;
+	int16 x;
+	int16 y;
+	const char *text;
+};
+
+static 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[] = {
+	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[] = {
+	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[] = {
+	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,
+	12, 10, 8, 8, 8, 6, 8, 8, 10, 8, 8, 8, 8, 8, 4, 6, 10,
+	4, 10, 4, 6, 4, 4, 6, 6, 8, 4, 8, 8, 14, 10, 4, 8, 8,
+	6, 6, 6, 6, 8, 6, 4, 6
+};
+
+static const RA2PSXMovieFont kMovieBigFont = {
+	"fNT24b",
+	"ABCDEFGHIJKLMN" "\x01" "OPQRSTUVWXYZ-" "\x01"
+	"abcdefghijklmn," "\x01" "opqrstuvwxyz%." "\x01" "0123456789? ",
+	kMovieBigWidths, kMovieBigAdvances, 0, 16, 16
+};
+
+static const RA2PSXMovieFont kMovieSmallFont = {
+	"fNT24s",
+	"abcdefghijkl" "\x01" "mnopqrstuvwx" "\x01" "yz0123456789" "\x01"
+	"%-:.? /{}[]|",
+	kMovieSmallWidths, nullptr, 12, 10, 10
+};
+
+static const RA2PSXMovieFont kMovieTinyFont = {
+	"fNT24t",
+	"ABCDEFGHIJ" "\x01" "KLMNOPQRST" "\x01" "UVWXYZabcd" "\x01"
+	"efghijklmn" "\x01" "opqrstuvwx" "\x01" "yz01234567" "\x01"
+	"89!'+,-./:" "\x01" "^()*;?=%_ " "\x01" "<>`[\\]@{|}",
+	kMovieTinyWidths, nullptr, 16, 16, 17
+};
+
+static 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" },
+	{ 758, 30, 1, 2, 40,   0,  85, "original design and story" },
+	{ 758, 30, 7, 129, 40,  0, 100, "Vince Lee" },
+	{ 796, 30, 1, 2, 40,   0,  85, "lead programmer" },
+	{ 796, 30, 7, 129, 40,  0, 100, "Jens Petersam" },
+	{ 834, 30, 1, 2, 40,   0,  85, "lead artist/animator" },
+	{ 834, 30, 7, 129, 40,  0, 100, "Richard Green" },
+	{ 872, 30, 1, 2, 40,   0,  80, "sound designers" },
+	{ 872, 30, 7, 129, 40,  0,  95, "Rudolf Stember" },
+	{ 872, 30, 7, 129, 40,  0, 115, "Larry the O" },
+	{ 910, 30, 1, 2, 40,   0,  85, "director of live action" },
+	{ 910, 30, 7, 129, 40,  0, 100, "Hal Barwood" },
+	{ 948, 68, 0, 2, 40, 146,  55, "artist-animators" },
+	{ 948, 34, 6, 129, 40, 60,  70, "Richard Green" },
+	{ 948, 34, 6, 129, 40, -1,  70, "Garry M.Gaber" },
+	{ 948, 34, 6, 129, 40, 60,  90, "Jon Knoles" },
+	{ 948, 34, 6, 129, 40, -1,  90, "Craig Rundels" },
+	{ 948, 34, 6, 129, 40, 60, 110, "Daniel Colon Jr." },
+	{ 948, 34, 6, 129, 40, -1, 110, "Clint Young" },
+	{ 948, 34, 6, 129, 40, 60, 130, "Ron K. Lussier" },
+	{ 948, 34, 6, 129, 40, -1, 130, "Seth Piezas" },
+	{ 982, 34, 6, 129, 40, 60,  70, "Eric Ingerson" },
+	{ 982, 34, 6, 129, 40, -1,  70, "Bill Stoneham" },
+	{ 982, 34, 6, 129, 40, 60,  90, "Mario Wagner" },
+	{ 982, 34, 6, 129, 40, -1,  90, "Andreas Escher" },
+	{ 982, 34, 0, 2, 40, 214, 110, "and" },
+	{ 982, 34, 6, 129, 40, 176, 130, "Mechadeus" },
+	{ 1024, 30, 0, 2, 40, 146, 85, "real-time models" },
+	{ 1024, 30, 6, 129, 40, 150, 100, "Tobias J. Richter" },
+	{ 1062, 30, 0, 2, 40, 40, 50, "lead video effects compositor" },
+	{ 1062, 30, 6, 129, 40, 100, 65, "Mark Christiansen" },
+	{ 1062, 30, 0, 2, 40, 160, 100, "video effects compositor" },
+	{ 1062, 30, 6, 129, 40, 230, 115, "Chris Weakley" },
+	{ 1100, 30, 0, 2, 40, 100, 50, "lead art technician" },
+	{ 1100, 30, 6, 129, 40, 120, 65, "Aaron Muszalski" },
+	{ 1100, 30, 0, 2, 40, 200, 100, "art technician" },
+	{ 1100, 30, 6, 129, 40, 200, 115, "Doug Shannon" },
+	{ 1138, 30, 1, 2, 40,   0,  80, "additional programmers" },
+	{ 1138, 30, 7, 129, 40,  0,  95, "Thomas Engel" },
+	{ 1138, 30, 7, 129, 40,  0, 115, "Holger Schmidt" },
+	{ 1176, 30, 1, 2, 40,   0,  85, "voice director and producer" },
+	{ 1176, 30, 7, 129, 40,  0, 100, "Tamlynn Barra" },
+	{ 1214, 30, 1, 2, 40,   0,  85, "lead tester" },
+	{ 1214, 30, 7, 129, 40,  0, 100, "Matthew Azeveda" },
+	{ 1252, 30, 1, 2, 40,   0,  85, "producer - factor 5" },
+	{ 1252, 30, 7, 129, 40,  0, 100, "Julian Eggebrecht" },
+	{ 1290, 30, 1, 2, 40,   0,  85, "director of production" },
+	{ 1290, 30, 7, 129, 40,  0, 100, "Steve Dauterman" },
+	{ 1328, 30, 1, 2, 40,   0,  80, "production coordinators" },
+	{ 1328, 30, 7, 129, 40,  0,  95, "Rachel Bryant" },
+	{ 1328, 30, 7, 129, 40,  0, 115, "Peggy Stok" },
+	{ 1366, 30, 1, 2, 40,   0,  85, "special thanks to" },
+	{ 1366, 30, 7, 129, 40,  0, 100, "George Lucas" }
+};
+
+static 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,
+		RA2PSXMovieGlyph &glyph) {
+	int metric = 0;
+	int row = 0;
+	int x = 0;
+	for (const char *entry = font.characters; *entry; ++entry) {
+		if (*entry == '\x01') {
+			++row;
+			x = 0;
+			continue;
+		}
+		if (*entry == character) {
+			glyph.x = x;
+			glyph.row = row;
+			glyph.width = font.widths[metric];
+			return true;
+		}
+		x += font.advances ? font.advances[metric] : font.fixedAdvance;
+		++metric;
+	}
+	return false;
+}
+
+static const RA2PSXMovieFont &getMovieFont(byte style) {
+	if (style == 2 || style == 3)
+		return kMovieBigFont;
+	if (style == 6 || style == 7)
+		return kMovieTinyFont;
+	return kMovieSmallFont;
+}
+
+static int measureMovieText(const RA2PSXMovieFont &font, const char *text,
+		uint characters, int spacing) {
+	int width = 0;
+	uint drawn = 0;
+	for (; *text && drawn < characters; ++text, ++drawn) {
+		RA2PSXMovieGlyph glyph;
+		if (findMovieGlyph(font, *text, glyph))
+			width += glyph.width + spacing;
+	}
+	return width ? width - spacing : 0;
+}
+
+static 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) {
+	for (uint offset = 0; offset + 40 <= data.size(); offset += 4) {
+		if (!memcmp(data.data() + offset, "fNT24s", 7) &&
+				!memcmp(data.data() + offset + 16, "fNT24b", 7) &&
+				!memcmp(data.data() + offset + 32, "fNT24t", 7))
+			return offset;
+	}
+	return -1;
+}
+
+bool RA2PSXMovieText::load(Common::SeekableReadStream &executable) {
+	if (executable.size() <= 0 || executable.size() > 2 * 1024 * 1024)
+		return false;
+
+	Common::Array<byte> executableData;
+	executableData.resize((uint32)executable.size());
+	executable.seek(0);
+	if (executable.read(executableData.data(), executableData.size()) != executableData.size())
+		return false;
+	const int archiveOffset = findMovieFontArchive(executableData);
+	if (archiveOffset < 0)
+		return false;
+
+	Common::MemoryReadStream stream(executableData.data() + archiveOffset,
+			executableData.size() - archiveOffset);
+	RA2PSXArchive archive;
+	if (!archive.load(stream))
+		return false;
+
+	Common::Array<byte> data;
+	_textures.clear();
+	// These sheets are packed RGB24, despite being uploaded as 16-bit VRAM words.
+	return archive.getMember("fNT24s", data) &&
+			_textures.appendRaw24("fNT24s", data, 144, 120) &&
+			archive.getMember("fNT24b", data) &&
+			_textures.appendRaw24("fNT24b", data, 128, 80) &&
+			archive.getMember("fNT24t", data) &&
+			_textures.appendRaw24("fNT24t", data, 160, 153);
+}
+
+void RA2PSXMovieText::draw(Graphics::Surface &surface,
+		RA2PSXMovieTextSequence sequence, int frame, int xOffset, int yOffset) const {
+	const RA2PSXMovieTextRecord *records = nullptr;
+	uint recordCount = 0;
+	if (sequence == kRA2PSXMovieTextOpening) {
+		records = kOpeningText;
+		recordCount = ARRAYSIZE(kOpeningText);
+	} else if (sequence == kRA2PSXMovieTextChapter1) {
+		records = kChapter1Text;
+		recordCount = ARRAYSIZE(kChapter1Text);
+	}
+
+	for (uint i = 0; i < recordCount; ++i) {
+		const RA2PSXMovieTextRecord &record = records[i];
+		if (frame < record.firstFrame || frame >= record.firstFrame + record.duration)
+			continue;
+
+		const RA2PSXMovieFont &font = getMovieFont(record.style);
+		const uint characters = MIN<uint>(40,
+				record.initialCharacters + frame - record.firstFrame);
+		const int spacing = record.style < 4 ? 2 : 0;
+		const int textWidth = measureMovieText(font, record.text, characters, spacing);
+		int movieX;
+		if (record.style & 1)
+			movieX = (480 - textWidth * 3 / 2) / 2;
+		else if (record.x < 0)
+			movieX = 360 - textWidth;
+		else
+			movieX = record.x;
+		int x = xOffset + scaleMovieX(movieX);
+
+		uint drawn = 0;
+		for (const char *text = record.text; *text && drawn < characters; ++text, ++drawn) {
+			RA2PSXMovieGlyph glyph;
+			if (!findMovieGlyph(font, *text, glyph))
+				continue;
+			int sourceY = glyph.row * font.rowStep;
+			if (record.style < 2)
+				sourceY += MIN<int>(record.color, 2) * 40;
+			_textures.draw(surface, font.texture, x, yOffset + record.y,
+					Common::Rect(glyph.x, sourceY, glyph.x + glyph.width,
+							sourceY + font.height));
+			x += glyph.width + spacing;
+		}
+	}
+}
+
 bool RA2PSXMainMenuUI::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 2e9ce8e6a1d..04d23f629b7 100644
--- a/engines/scumm/insane/rebel2/psx/ui.h
+++ b/engines/scumm/insane/rebel2/psx/ui.h
@@ -30,6 +30,8 @@ public:
 
 	void clear() { _textures.clear(); }
 	bool append(const Common::Array<byte> &data);
+	bool appendRaw24(const char *name, const Common::Array<byte> &data,
+			uint16 width, uint16 height);
 	bool has(const char *name) const { return find(name) != nullptr; }
 	void draw(Graphics::Surface &surface, const char *name, int x, int y,
 			const Common::Rect &source, int brightness = 0x80,
@@ -42,6 +44,16 @@ private:
 	Common::Array<RA2PSXTexture> _textures;
 };
 
+class RA2PSXMovieText {
+public:
+	bool load(Common::SeekableReadStream &executable);
+	void draw(Graphics::Surface &surface, RA2PSXMovieTextSequence sequence,
+			int frame, int xOffset, int yOffset) const;
+
+private:
+	RA2PSXTextureSet _textures;
+};
+
 class RA2PSXMainMenuUI {
 public:
 	bool load(const RA2PSXArchive &archive);


Commit: 1fb9617cfa5a5df05f416d1fee213612ba194710
    https://github.com/scummvm/scummvm/commit/1fb9617cfa5a5df05f416d1fee213612ba194710
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-24T13:32:28+02:00

Commit Message:
SCUMM: RA2: re-use common L1 code for psx release

Changed paths:
    engines/scumm/insane/rebel2/psx/psx.cpp
    engines/scumm/insane/rebel2/psx/psx.h
    engines/scumm/insane/rebel2/rebel.h
    engines/scumm/insane/rebel2/runlevels.cpp
    engines/scumm/insane/rebel2/shared.h


diff --git a/engines/scumm/insane/rebel2/psx/psx.cpp b/engines/scumm/insane/rebel2/psx/psx.cpp
index 82465dbe4b0..36265c007d2 100644
--- a/engines/scumm/insane/rebel2/psx/psx.cpp
+++ b/engines/scumm/insane/rebel2/psx/psx.cpp
@@ -28,6 +28,7 @@
 #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"
 #include "scumm/insane/rebel2/psx/video.h"
@@ -37,6 +38,79 @@ namespace Scumm {
 Rebel2PSX::Rebel2PSX(ScummEngine_v7 *vm) : _vm(vm) {
 }
 
+class Rebel2PSX::Level1Handler : public Rebel2Level1Handler {
+public:
+	enum Error {
+		kGameplayError,
+		kEndingError,
+		kDeathVideoError,
+		kGameOverVideoError
+	};
+
+	Level1Handler(Rebel2PSX &psx, const RA2PSXModel &enemy, const RA2PSXModel &ship,
+			const RA2PSXModel &crosshair, const RA2PSXModel &laser,
+			const RA2PSXLevel1UI &ui, int &score) :
+		_psx(psx), _enemy(enemy), _ship(ship), _crosshair(crosshair), _laser(laser),
+		_ui(ui), _score(score), _error(kGameplayError) {
+	}
+
+	bool shouldQuit() const override {
+		return _psx._vm->shouldQuit();
+	}
+
+	Result playAttempt(int &lives) override {
+		switch (_psx.playLevel1(_enemy, _ship, _crosshair, _laser, _ui, lives, _score)) {
+		case Rebel2PSX::kLevel1Quit:
+			return kQuit;
+		case Rebel2PSX::kLevel1Complete:
+			return kComplete;
+		case Rebel2PSX::kLevel1Death:
+			return kDeath;
+		case Rebel2PSX::kLevel1Error:
+			return kError;
+		}
+
+		return kError;
+	}
+
+	bool playComplete() override {
+		return playVideo("S1/L01_EXTR.STR", kEndingError);
+	}
+
+	bool playDeath() override {
+		return playVideo("S1/L01_DIE.STR", kDeathVideoError);
+	}
+
+	bool playRetry(int) override {
+		return true;
+	}
+
+	bool playGameOver(int) override {
+		return playVideo("S1/L01_OVER.STR", kGameOverVideoError);
+	}
+
+	Error getError() const {
+		return _error;
+	}
+
+private:
+	bool playVideo(const char *path, Error error) {
+		if (_psx.playVideo(path, 1, false))
+			return true;
+		_error = error;
+		return false;
+	}
+
+	Rebel2PSX &_psx;
+	const RA2PSXModel &_enemy;
+	const RA2PSXModel &_ship;
+	const RA2PSXModel &_crosshair;
+	const RA2PSXModel &_laser;
+	const RA2PSXLevel1UI &_ui;
+	int &_score;
+	Error _error;
+};
+
 Common::SeekableReadStream *Rebel2PSX::openResource(int number) {
 	const Common::Path path(Common::String::format("RESOURCE.%03d", number));
 	Common::File *file = new Common::File();
@@ -228,34 +302,24 @@ Common::Error Rebel2PSX::runGame() {
 
 	int lives = 3;
 	int score = 0;
-	while (!_vm->shouldQuit()) {
-		const Level1Result result = playLevel1(enemy, ship, crosshair, laser, ui, lives, score);
-		if (result == kLevel1Quit)
-			return Common::kNoError;
-		if (result == kLevel1Error)
-			return Common::Error(Common::kReadingFailed,
-					_("Could not run the PlayStation Level 1 gameplay"));
-		if (result == kLevel1Complete) {
-			if (!playVideo("S1/L01_EXTR.STR", 1, false) && !_vm->shouldQuit())
-				return Common::Error(Common::kReadingFailed,
-						_("Could not play the PlayStation Level 1 ending"));
-			return Common::kNoError;
-		}
+	Level1Handler handler(*this, enemy, ship, crosshair, laser, ui, score);
+	if (runRebel2Level1(handler, lives) != Rebel2Level1Handler::kError)
+		return Common::kNoError;
 
-		if (!playVideo("S1/L01_DIE.STR", 1, false)) {
-			if (_vm->shouldQuit())
-				return Common::kNoError;
-			return Common::Error(Common::kReadingFailed,
-					_("Could not play the PlayStation Level 1 death video"));
-		}
-		if (--lives == 0) {
-			if (!playVideo("S1/L01_OVER.STR", 1, false) && !_vm->shouldQuit())
-				return Common::Error(Common::kReadingFailed,
-						_("Could not play the PlayStation game-over video"));
-			return Common::kNoError;
-		}
+	switch (handler.getError()) {
+	case Level1Handler::kEndingError:
+		return Common::Error(Common::kReadingFailed,
+				_("Could not play the PlayStation Level 1 ending"));
+	case Level1Handler::kDeathVideoError:
+		return Common::Error(Common::kReadingFailed,
+				_("Could not play the PlayStation Level 1 death video"));
+	case Level1Handler::kGameOverVideoError:
+		return Common::Error(Common::kReadingFailed,
+				_("Could not play the PlayStation game-over video"));
+	default:
+		return Common::Error(Common::kReadingFailed,
+				_("Could not run the PlayStation Level 1 gameplay"));
 	}
-	return Common::kNoError;
 #else
 	return Common::Error(Common::kUnsupportedGameidError,
 			_s("Rebel Assault II PlayStation support requires TinyGL"));
diff --git a/engines/scumm/insane/rebel2/psx/psx.h b/engines/scumm/insane/rebel2/psx/psx.h
index fb6ad53ccb5..3f09bb80398 100644
--- a/engines/scumm/insane/rebel2/psx/psx.h
+++ b/engines/scumm/insane/rebel2/psx/psx.h
@@ -228,6 +228,8 @@ public:
 	Common::Error runGame();
 
 private:
+	class Level1Handler;
+
 	enum MenuResult {
 		kMenuStart,
 		kMenuQuit
diff --git a/engines/scumm/insane/rebel2/rebel.h b/engines/scumm/insane/rebel2/rebel.h
index d3af0ee0c96..ed180e99ffc 100644
--- a/engines/scumm/insane/rebel2/rebel.h
+++ b/engines/scumm/insane/rebel2/rebel.h
@@ -270,6 +270,7 @@ public:
 	Common::String getLevelPrefix(int levelId);
 
 	// Per-level handlers.
+	class Level1Handler;
 	int runLevel1();
 	int runLevel2();
 	int runLevel3();
diff --git a/engines/scumm/insane/rebel2/runlevels.cpp b/engines/scumm/insane/rebel2/runlevels.cpp
index 96da9fd3a58..66c0f2889bc 100644
--- a/engines/scumm/insane/rebel2/runlevels.cpp
+++ b/engines/scumm/insane/rebel2/runlevels.cpp
@@ -26,53 +26,112 @@
 #include "scumm/smush/smush_player.h"
 
 #include "scumm/insane/rebel2/rebel.h"
+#include "scumm/insane/rebel2/shared.h"
 
 namespace Scumm {
 
-int InsaneRebel2::runLevel1() {
-	playLevelBegin(1);
-	if (_vm->shouldQuit())
-		return kLevelQuit;
+Rebel2Level1Handler::Result runRebel2Level1(Rebel2Level1Handler &handler, int lives) {
+	while (!handler.shouldQuit()) {
+		const Rebel2Level1Handler::Result result = handler.playAttempt(lives);
+		if (result == Rebel2Level1Handler::kQuit || result == Rebel2Level1Handler::kError)
+			return result;
+		if (result == Rebel2Level1Handler::kComplete) {
+			if (!handler.playComplete())
+				return handler.shouldQuit() ? Rebel2Level1Handler::kQuit : Rebel2Level1Handler::kError;
+			return Rebel2Level1Handler::kComplete;
+		}
 
-	while (!_vm->shouldQuit()) {
-		_playerShield = 255;
-		_playerDamage = 0;
-		_deathFrame = 0;
-		resetExplosions();
+		if (!handler.playDeath())
+			return handler.shouldQuit() ? Rebel2Level1Handler::kQuit : Rebel2Level1Handler::kError;
+		if (handler.shouldQuit())
+			return Rebel2Level1Handler::kQuit;
 
-		clearBit(0);
-		_rebelKillCounter = 0;
-		_rebelHitCounter = 0;
+		--lives;
+		if (lives <= 0) {
+			if (!handler.playGameOver(lives))
+				return handler.shouldQuit() ? Rebel2Level1Handler::kQuit : Rebel2Level1Handler::kError;
+			return Rebel2Level1Handler::kGameOver;
+		}
 
-		if (!playLevelSegment("LEV01/01P01.SAN", 0x28))
-			return kLevelQuit;
+		if (!handler.playRetry(lives))
+			return handler.shouldQuit() ? Rebel2Level1Handler::kQuit : Rebel2Level1Handler::kError;
+		if (handler.shouldQuit())
+			return Rebel2Level1Handler::kQuit;
+	}
 
-		if (_playerShield > 0) {
-			int accuracy = calculateAccuracy(_rebelKillCounter, _rebelHitCounter);
-			debugC(DEBUG_INSANE, "Level 1 completed!");
-			playLevelEnd(1, accuracy, -1, false);
-			_levelUnlocked[1] = true;
-			return kLevelNextLevel;
-		}
+	return Rebel2Level1Handler::kQuit;
+}
 
-		debugC(DEBUG_INSANE, "Level 1 death at frame %d, lives=%d", _deathFrame, _playerLives - 1);
-		playLevelDeathVariant(1, 1, _deathFrame);
+class InsaneRebel2::Level1Handler : public Rebel2Level1Handler {
+public:
+	explicit Level1Handler(InsaneRebel2 &rebel) : _rebel(rebel) {}
 
-		if (_vm->shouldQuit())
-			return kLevelQuit;
+	bool shouldQuit() const override {
+		return _rebel._vm->shouldQuit();
+	}
 
-		_playerLives--;
-		if (_playerLives <= 0) {
-			playLevelGameOver(1);
-			return kLevelGameOver;
-		}
+	Result playAttempt(int &lives) override {
+		_rebel._playerLives = lives;
+		_rebel._playerShield = 255;
+		_rebel._playerDamage = 0;
+		_rebel._deathFrame = 0;
+		_rebel.resetExplosions();
 
-		playLevelRetry(1);
-		if (_vm->shouldQuit())
-			return kLevelQuit;
+		_rebel.clearBit(0);
+		_rebel._rebelKillCounter = 0;
+		_rebel._rebelHitCounter = 0;
+
+		if (!_rebel.playLevelSegment("LEV01/01P01.SAN", 0x28))
+			return kQuit;
+
+		lives = _rebel._playerLives;
+		return _rebel._playerShield > 0 ? kComplete : kDeath;
 	}
 
-	return kLevelQuit;
+	bool playComplete() override {
+		const int accuracy = _rebel.calculateAccuracy(_rebel._rebelKillCounter, _rebel._rebelHitCounter);
+		debugC(DEBUG_INSANE, "Level 1 completed!");
+		_rebel.playLevelEnd(1, accuracy, -1, false);
+		_rebel._levelUnlocked[1] = true;
+		return true;
+	}
+
+	bool playDeath() override {
+		debugC(DEBUG_INSANE, "Level 1 death at frame %d, lives=%d", _rebel._deathFrame, _rebel._playerLives - 1);
+		_rebel.playLevelDeathVariant(1, 1, _rebel._deathFrame);
+		return true;
+	}
+
+	bool playRetry(int lives) override {
+		_rebel._playerLives = lives;
+		_rebel.playLevelRetry(1);
+		return true;
+	}
+
+	bool playGameOver(int lives) override {
+		_rebel._playerLives = lives;
+		_rebel.playLevelGameOver(1);
+		return true;
+	}
+
+private:
+	InsaneRebel2 &_rebel;
+};
+
+int InsaneRebel2::runLevel1() {
+	playLevelBegin(1);
+	if (_vm->shouldQuit())
+		return kLevelQuit;
+
+	Level1Handler handler(*this);
+	switch (runRebel2Level1(handler, _playerLives)) {
+	case Rebel2Level1Handler::kComplete:
+		return kLevelNextLevel;
+	case Rebel2Level1Handler::kGameOver:
+		return kLevelGameOver;
+	default:
+		return kLevelQuit;
+	}
 }
 
 InsaneRebel2::WaveEndResult InsaneRebel2::processWaveEnd(int16 mask, int16 *budget, int16 threshold, uint16 flags) {
diff --git a/engines/scumm/insane/rebel2/shared.h b/engines/scumm/insane/rebel2/shared.h
index 3476a8c40de..f143d3488f1 100644
--- a/engines/scumm/insane/rebel2/shared.h
+++ b/engines/scumm/insane/rebel2/shared.h
@@ -30,6 +30,27 @@ enum {
 	kRebel2MenuResultNone = -1
 };
 
+class Rebel2Level1Handler {
+public:
+	enum Result {
+		kQuit,
+		kComplete,
+		kDeath,
+		kGameOver,
+		kError
+	};
+
+	virtual ~Rebel2Level1Handler() {}
+	virtual bool shouldQuit() const = 0;
+	virtual Result playAttempt(int &lives) = 0;
+	virtual bool playComplete() = 0;
+	virtual bool playDeath() = 0;
+	virtual bool playRetry(int lives) = 0;
+	virtual bool playGameOver(int lives) = 0;
+};
+
+Rebel2Level1Handler::Result runRebel2Level1(Rebel2Level1Handler &handler, int lives);
+
 inline Rebel2MenuCommand getRebel2MenuCommand(const Common::KeyState &key) {
 	switch (key.keycode) {
 	case Common::KEYCODE_UP:




More information about the Scummvm-git-logs mailing list