Maelstrom: Added a zoomed mode on phones

From 81532ebda5827873937474aca8f0c1ea16da34b0 Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Sun, 22 Mar 2026 23:33:28 -0700
Subject: [PATCH] Added a zoomed mode on phones

---
 .../app/HIDDeviceBLESteamController.java      | 128 +++++++++++++++---
 .../java/org/libsdl/app/HIDDeviceManager.java |   8 +-
 .../main/java/org/libsdl/app/SDLActivity.java |   4 +-
 external/SDL                                  |   2 +-
 game/game.cpp                                 | 106 +++++++++++++++
 game/game.h                                   |   5 +
 screenlib/SDL_FrameBuf.cpp                    |  12 +-
 screenlib/SDL_FrameBuf.h                      |   4 +
 screenlib/UIManager.cpp                       |   5 +-
 9 files changed, 250 insertions(+), 24 deletions(-)

diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
index bf1ca214..97b4c127 100644
--- a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -19,9 +19,13 @@
 
 import java.lang.Runnable;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.UUID;
 
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+
 class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
 
     private static final String TAG = "hidapi";
@@ -37,6 +41,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe
     private LinkedList<GattOperation> mOperations;
     GattOperation mCurrentOperation = null;
     private Handler mHandler;
+    private int mProductId = -1;
+
+    private static final int D0G_BLE2_PID = 0x1106;
+    private static final int TRITON_BLE_PID = 0x1303;
+
 
     private static final int TRANSPORT_AUTO = 0;
     private static final int TRANSPORT_BREDR = 1;
@@ -45,10 +54,13 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe
     private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
 
     static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
-    static final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+    static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+    static final UUID inputCharacteristicTriton = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3");
     static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
     static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
 
+    private HashMap<Integer, BluetoothGattCharacteristic> mOutputReportChars = new HashMap<Integer, BluetoothGattCharacteristic>();
+
     static class GattOperation {
         private enum Operation {
             CHR_READ,
@@ -314,8 +326,38 @@ private boolean probeService(HIDDeviceBLESteamController controller) {
                 Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
 
                 for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
-                    if (chr.getUuid().equals(inputCharacteristic)) {
-                        Log.v(TAG, "Found input characteristic");
+                    boolean bShouldStartNotifications = false;
+
+                    if (chr.getUuid().equals(inputCharacteristicTriton)) {
+                        Log.v(TAG, "Found Triton input characteristic");
+                        mProductId = TRITON_BLE_PID;
+                        bShouldStartNotifications = true;
+                    } else if (chr.getUuid().equals(inputCharacteristicD0G)) {
+                        Log.v(TAG, "Found D0G input characteristic");
+                        mProductId = D0G_BLE2_PID;
+                        bShouldStartNotifications = true;
+                    } else {
+                        Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE);
+                        Matcher matcher = reportPattern.matcher(chr.getUuid().toString());
+
+                        if (matcher.find()) {
+                            try {
+                                int reportId = Integer.parseInt(matcher.group(1), 16);
+
+                                reportId -= 0x35;
+                                if (reportId >= 0x80) {
+                                    // This is a Triton output report characteristic that we need to care about.
+                                    Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16));
+                                    mOutputReportChars.put(reportId, chr);
+                                }
+                            }
+                            catch (NumberFormatException nfe) {
+                                Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString());
+                            }
+                        }
+                    }
+
+                    if (bShouldStartNotifications) {
                         // Start notifications
                         BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
                         if (cccd != null) {
@@ -448,8 +490,16 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
                 mIsConnected = false;
                 gatt.disconnect();
                 mGatt = connectGatt(false);
-            }
-            else {
+            } else {
+                if (getProductId() == TRITON_BLE_PID) {
+                    // Android will not properly play well with Data Length Extensions without manually requesting a large MTU,
+                    // and Triton controllers require DLE support.
+                    //
+                    // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change
+                    // this value. It is functionally "please enable data length extensions" on some Android builds.
+                    mGatt.requestMtu(517);
+                }
+
                 probeService(this);
             }
         }
@@ -487,7 +537,7 @@ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteris
     // Enable this for verbose logging of controller input reports
         //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
 
-        if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
+        if (characteristic.getUuid().equals(getInputCharacteristic()) && !mFrozen) {
             mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
         }
     }
@@ -502,13 +552,21 @@ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descri
         BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
         //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
 
-        if (chr.getUuid().equals(inputCharacteristic)) {
+        if (chr.getUuid().equals(getInputCharacteristic())) {
             boolean hasWrittenInputDescriptor = true;
             BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
             if (reportChr != null) {
-                Log.v(TAG, "Writing report characteristic to enter valve mode");
-                reportChr.setValue(enterValveMode);
-                gatt.writeCharacteristic(reportChr);
+                if (getProductId() == TRITON_BLE_PID) {
+                    // For Triton we just mark things registered.
+                    Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId());
+                    mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true);
+                    setRegistered();
+                } else {
+                    // For the original controller, we need to manually enter Valve mode.
+                    Log.v(TAG, "Writing report characteristic to enter valve mode");
+                    reportChr.setValue(enterValveMode);
+                    gatt.writeCharacteristic(reportChr);
+                }
             }
         }
 
@@ -548,9 +606,28 @@ public int getVendorId() {
 
     @Override
     public int getProductId() {
-        // We don't have an easy way to query from the Bluetooth device, but we know what it is
-        final int D0G_BLE2_PID = 0x1106;
-        return D0G_BLE2_PID;
+        if (mProductId > 0) {
+            // We've already set a product ID.
+            return mProductId;
+        }
+
+        if (mDevice.getName().startsWith("Steam Ctrl")) {
+            // We're a newer Triton device
+            mProductId = TRITON_BLE_PID;
+        } else {
+            // We're an OG Steam Controller
+            mProductId = D0G_BLE2_PID;
+        }
+
+        return mProductId;
+    }
+
+    private UUID getInputCharacteristic() {
+        if (getProductId() == TRITON_BLE_PID) {
+            return inputCharacteristicTriton;
+        } else {
+            return inputCharacteristicD0G;
+        }
     }
 
     @Override
@@ -601,10 +678,29 @@ public int writeReport(byte[] report, boolean feature) {
             writeCharacteristic(reportCharacteristic, actual_report);
             return report.length;
         } else {
-            //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
-            writeCharacteristic(reportCharacteristic, report);
-            return report.length;
+            // If we're an original-recipe Steam Controller we just write to the characteristic directly.
+            if (getProductId() == D0G_BLE2_PID) {
+                //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
+                writeCharacteristic(reportCharacteristic, report);
+                return report.length;                
+            }
+
+            // If we're a Triton, we need to find the correct report characteristic.
+            if (report.length > 0) {
+                int reportId = report[0];
+                BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId);
+                if (targetedReportCharacteristic != null) {
+                    byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+                    //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report));
+                    writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report);
+                    return report.length;
+                } else {
+                    Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16));
+                }
+            }
         }
+
+        return -1;
     }
 
     @Override
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
index 1fb2bfb4..b00c905d 100644
--- a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
@@ -529,7 +529,13 @@ boolean isSteamController(BluetoothDevice bluetoothDevice) {
             return false;
         }
 
-        return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
+        // Steam Controllers will always support Bluetooth Low Energy
+        if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) {
+            return false;
+        }
+
+        // Match on the name either the original Steam Controller or the new second-generation one advertise with.
+        return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl");
     }
 
     private void close() {
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
index 0ca7b118..dcc61d87 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -60,8 +60,8 @@
 public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener {
     private static final String TAG = "SDL";
     private static final int SDL_MAJOR_VERSION = 3;
-    private static final int SDL_MINOR_VERSION = 4;
-    private static final int SDL_MICRO_VERSION = 3;
+    private static final int SDL_MINOR_VERSION = 5;
+    private static final int SDL_MICRO_VERSION = 0;
 /*
     // Display InputType.SOURCE/CLASS of events and devices
     //
diff --git a/external/SDL b/external/SDL
index b7e46361..122ad3d6 160000
--- a/external/SDL
+++ b/external/SDL
@@ -1 +1 @@
-Subproject commit b7e46361f5668ba313dd393170265cdd04d8e6e9
+Subproject commit 122ad3d6f600229dbb4f52883a907699cd3a1acd
diff --git a/game/game.cpp b/game/game.cpp
index d6939297..97550c6b 100644
--- a/game/game.cpp
+++ b/game/game.cpp
@@ -184,6 +184,8 @@ GamePanelDelegate::OnLoad()
 	m_fragsLabel = m_panel->GetElement<UIElement>("frags_label");
 	m_frags = m_panel->GetElement<UIElement>("frags");
 
+	m_zoom = false;
+
 	return true;
 }
 
@@ -192,6 +194,8 @@ GamePanelDelegate::OnShow()
 {
 	int i;
 
+	UpdateZoom();
+
 	SetSteamTimelineMode(STEAM_TIMELINE_PLAYING);
 
 	/* Initialize some game variables */
@@ -481,6 +485,10 @@ GamePanelDelegate::OnDraw(DRAWLEVEL drawLevel)
 		return;
 	}
 
+	if (m_zoom) {
+		StartZoomedDrawing();
+	}
+
 	/* -- Draw the star field */
 	for ( i=0; i<MAX_STARS; ++i ) {
 		int x = (gTheStars[i]->xCoord << SPRITE_PRECISION);
@@ -498,6 +506,10 @@ GamePanelDelegate::OnDraw(DRAWLEVEL drawLevel)
 		}
 		gPlayers[i]->BlitSprite();
 	}
+
+	if (m_zoom) {
+		StopZoomedDrawing();
+	}
 }
 
 bool
@@ -507,6 +519,9 @@ GamePanelDelegate::HandleEvent(const SDL_Event &event)
 		if (m_touchControls) {
 			m_touchControls->Show();
 		}
+	} else if (event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED ||
+	           event.type == SDL_EVENT_WINDOW_SAFE_AREA_CHANGED) {
+		UpdateZoom();
 	}
 	return false;
 }
@@ -567,6 +582,94 @@ GamePanelDelegate::OnAction(UIBaseElement *sender, const char *action)
 	return true;
 }
 
+void
+GamePanelDelegate::UpdateZoom()
+{
+	SDL_Renderer *renderer = screen->GetRenderer();
+	SDL_Rect rect;
+	int saved_w, saved_h;
+	SDL_RendererLogicalPresentation saved_mode;
+	SDL_GetRenderLogicalPresentation(renderer, &saved_w, &saved_h, &saved_mode);
+	SDL_SetRenderLogicalPresentation(renderer, 0, 0, SDL_LOGICAL_PRESENTATION_DISABLED);
+	SDL_GetRenderSafeArea(renderer, &rect);
+	SDL_SetRenderLogicalPresentation(renderer, saved_w, saved_h, saved_mode);
+
+	// We can zoom if we're on a phone in landscape mode and not local multiplayer
+	bool zoom = false;
+
+	if (SDL_IsPhone() && rect.w > rect.h) {
+		int i;
+
+		int local_players = 0;
+		OBJ_LOOP(i, MAX_PLAYERS) {
+			if (!gPlayers[i]->IsValid()) {
+				continue;
+			}
+
+			if (IS_LOCAL_CONTROL(gPlayers[i]->GetControlType())) {
+				++local_players;
+			}
+		}
+		if (local_players == 1) {
+			zoom = true;
+		}
+	}
+
+	if (zoom) {
+		float scale = (float)GAME_WIDTH / rect.w;
+		int x = (int)SDL_round(rect.x * scale);
+		int y = (int)SDL_round(rect.y * scale);
+		int height = (int)SDL_round(rect.h * scale);
+		ui->SetPosition(x, y);
+		ui->SetSize(GAME_WIDTH, height);
+	} else {
+		ui->SetPosition(0, 0);
+		ui->SetSize(GAME_WIDTH, GAME_HEIGHT);
+	}
+	m_zoom = zoom;
+}
+
+void
+GamePanelDelegate::StartZoomedDrawing()
+{
+	SDL_Renderer *renderer = screen->GetRenderer();
+	SDL_SetRenderLogicalPresentation(renderer, 0, 0, SDL_LOGICAL_PRESENTATION_DISABLED);
+	int w = 0, h = 0;
+	SDL_GetCurrentRenderOutputSize(renderer, &w, &h);
+	float scale = (float)w / GAME_WIDTH;
+
+	int playerX, playerY;
+	gPlayers[0]->GetPos(&playerX, &playerY);
+	GetRenderCoordinates(playerX, playerY);
+	playerY += (32 / 2);
+
+	SDL_SetRenderScale(renderer, scale, scale);
+
+	int half_viewable_height = (int)(gScrnRect.h / 2 * ((float)gScrnRect.w / gScrnRect.h) / (((float)w / h)));
+	int shortfall = (int)(gScrnRect.h - (h / scale));
+	SDL_Rect viewport;
+	viewport.w = w;
+	viewport.h = h;
+	viewport.x = 0;
+	viewport.y = -playerY + half_viewable_height;
+	if (viewport.y > 0) {
+		viewport.y = 0;
+	} else if (viewport.y < -shortfall) {
+		viewport.y = -shortfall;
+	}
+	SDL_SetRenderViewport(renderer, &viewport);
+}
+
+void
+GamePanelDelegate::StopZoomedDrawing()
+{
+	SDL_Renderer *renderer = screen->GetRenderer();
+
+	SDL_SetRenderScale(renderer, 1.0f, 1.0f);
+	SDL_SetRenderViewport(renderer, nullptr);
+	SDL_SetRenderLogicalPresentation(renderer, ui->X() + ui->Width() + ui->X(), ui->Y() + ui->Height() + ui->Y(), SDL_LOGICAL_PRESENTATION_LETTERBOX);
+}
+
 /* ----------------------------------------------------------------- */
 /* -- Draw the status display */
 
@@ -1243,6 +1346,9 @@ GamePanelDelegate::GameOver()
 
 	DisableRemoteInput();
 
+	ui->SetPosition(0, 0);
+	ui->SetSize(GAME_WIDTH, GAME_HEIGHT);
+
 	ui->ShowPanel(PANEL_GAMEOVER);
 }
 
diff --git a/game/game.h b/game/game.h
index da5d9ebe..80243cd5 100644
--- a/game/game.h
+++ b/game/game.h
@@ -41,6 +41,9 @@ class GamePanelDelegate : public UIPanelDelegate
 	virtual bool OnAction(UIBaseElement *sender, const char *action);
 
 protected:
+	void UpdateZoom();
+	void StartZoomedDrawing();
+	void StopZoomedDrawing();
 	void DrawStatus(Bool first);
 	bool UpdateGameState();
 	void DoHousekeeping();
@@ -96,6 +99,8 @@ class GamePanelDelegate : public UIPanelDelegate
 		STATE_BONUS_HIDE,
 		STATE_START_NEXT_WAVE,
 	} m_state;
+
+	bool m_zoom;
 };
 
 /* ----------------------------------------------------------------- */
diff --git a/screenlib/SDL_FrameBuf.cpp b/screenlib/SDL_FrameBuf.cpp
index 37c06fe0..4bd1c6af 100644
--- a/screenlib/SDL_FrameBuf.cpp
+++ b/screenlib/SDL_FrameBuf.cpp
@@ -75,7 +75,7 @@ FrameBuf::Init(int width, int height, Uint32 window_flags, const char *title, SD
 	Clear();
 
 	/* Set the output area */
-	SDL_SetRenderLogicalPresentation(m_renderer, width, height, SDL_LOGICAL_PRESENTATION_LETTERBOX);
+	SetLogicalSize(width, height);
 
 	m_width = width;
 	m_height = height;
@@ -100,6 +100,16 @@ FrameBuf::~FrameBuf()
 	}
 }
 
+void
+FrameBuf::SetLogicalSize(int width, int height)
+{
+	if (width && height) {
+		SDL_SetRenderLogicalPresentation(m_renderer, width, height, SDL_LOGICAL_PRESENTATION_LETTERBOX);
+	} else {
+		SDL_SetRenderLogicalPresentation(m_renderer, 0, 0, SDL_LOGICAL_PRESENTATION_DISABLED);
+	}
+}
+
 void
 FrameBuf::ProcessEvent(SDL_Event *event)
 {
diff --git a/screenlib/SDL_FrameBuf.h b/screenlib/SDL_FrameBuf.h
index 0fd87762..dfb2f5d9 100644
--- a/screenlib/SDL_FrameBuf.h
+++ b/screenlib/SDL_FrameBuf.h
@@ -66,6 +66,7 @@ class FrameBuf : public ErrorBase {
 		m_clip.w = (float)cliprect->w;
 		m_clip.h = (float)cliprect->h;
 	}
+	void SetLogicalSize(int width, int height);
 
 	/* Event Routines */
 	void ProcessEvent(SDL_Event *event);
@@ -89,6 +90,9 @@ class FrameBuf : public ErrorBase {
 	SDL_Window *GetWindow() const {
 		return m_window;
 	}
+	SDL_Renderer *GetRenderer() const {
+		return m_renderer;
+	}
 	int Width() const {
 		return m_width;
 	}
diff --git a/screenlib/UIManager.cpp b/screenlib/UIManager.cpp
index cd316477..43d7f9c6 100644
--- a/screenlib/UIManager.cpp
+++ b/screenlib/UIManager.cpp
@@ -263,9 +263,7 @@ UIManager::ShowPanel(UIPanel *panel)
 		}
 
 		panel->Show();
-		if (panel->IsFullscreen()) {
-			Draw();
-		}
+
 		if (!panel->IsCursorVisible()) {
 			m_screen->HideCursor();
 		}
@@ -412,6 +410,7 @@ UIManager::Draw(bool tick)
 		}
 	}
 
+	m_screen->SetLogicalSize(X() + Width() + X(), Y() + Height() + Y());
 	m_screen->Clear();
 	for (i = 0; i < m_visible.length(); ++i) {
 		UIPanel *panel = m_visible[i];