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];