Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions EMULATOR_CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ the user's own Lossless Scaling installation, which they must own separately on
| Snapdragon Game Super Resolution (SGSR) | Upscaling shader | BSD-3-Clause | https://github.com/quic/snapdragon-gsr |
| Winlator | Windows-on-Android base this project forks | GPL-3.0 | https://github.com/brunodev85/winlator |

## Controller support

Steam Controller support and the visual controller test / binding screen were written by
**The412Banner** for **Bannerlator** and ported into WinNative. The SDL3 Android release AAR is
vendored unmodified under `vendor/maven/org/libsdl/android/SDL3/` and is only used to read Valve
HIDAPI devices; every other controller keeps WinNative's normal Android input path.

| Component | Role | License | Source |
| --- | --- | --- | --- |
| Bannerlator | Steam Controller backend, pad artwork and the controller test / visual binder this port is derived from | GPL-3.0 | https://github.com/The412Banner/Bannerlator |
| SDL3 3.4.16 (official Android release AAR, unmodified) | HIDAPI Steam Controller drivers | Zlib | https://github.com/libsdl-org/SDL |

## Source availability

WinNative is released under the GPL-3.0. As required by that license and by the GPL-2.0
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ Please match the existing code style and ensure any AI-assisted code is thorough
- **DIS optical flow** — the algorithm and its reference implementation come from [OpenCV](https://github.com/opencv/opencv) (`DISOpticalFlow`, [LICENSE](https://github.com/opencv/opencv/blob/5.x/LICENSE)), which adopted Till Kroeger's original [OF_DIS](https://github.com/tikroeger/OF_DIS)
- **DXVK** by [Philip Rebohle and contributors](https://github.com/doitsujin/dxvk) (zlib/libpng) — the `dxbc` shader translator, vendored at `app/src/main/cpp/thirdparty/dxbc` to convert the frame generation shaders to SPIR-V
- **DirectAudio** by [The412Banner](https://github.com/The412Banner/directaudio) (LGPL-2.1-or-later) — the native Wine → Android AAudio audio driver, and the only audio path in WinNative that carries a working microphone. See [DirectAudio — what came from The412Banner's driver](#directaudio--what-came-from-the412banners-driver) below
- **Steam Controller support and the visual controller test / binder** by [The412Banner](https://github.com/The412Banner/Bannerlator) (GPL-3.0) — written for **Bannerlator** and ported into WinNative, including the per-family pad artwork. See [Steam Controller — what came from The412Banner's Bannerlator](#steam-controller--what-came-from-the412banners-bannerlator) below
- **SDL3** by [Sam Lantinga and the SDL contributors](https://github.com/libsdl-org/SDL) (Zlib) — its HIDAPI Steam drivers are what actually read the Steam Controller; the official 3.4.16 Android release AAR is vendored unmodified at `vendor/maven/org/libsdl/android/SDL3/`
- **Lossless Scaling** (Steam) — the source of the frame generation shaders. They are read from the user's own installed copy at runtime; none are redistributed with WinNative

#### Frame generation — what came from Camille LaVey's Eden port
Expand Down Expand Up @@ -238,6 +240,66 @@ The engine sits behind its own settings — a Fast / Balance / Quality flow reso
optional target frame rate — kept separate from the Lossless Scaling ones, and the two engines
are mutually exclusive because the compositor drives one interpolator per frame.

#### Steam Controller — what came from The412Banner's Bannerlator

WinNative's Steam Controller support, the animated controller test panel and the tap-the-pad
visual binder are **The412Banner's work**, written for
**[Bannerlator](https://github.com/The412Banner/Bannerlator)** and ported here. The design, the
SDL3 bridge, the input model, the pad artwork and the hotspot manifest are all his; WinNative's
own part is the adaptation to this fork's input plumbing (`WinHandler`'s slot machinery, the
`InputControlsView` binding lane and the Compose settings screen) plus full localization.

Out of the box a Steam Controller is not a gamepad: it presents as a keyboard and mouse
("lizard mode"), so Android never reports a controller and WinNative's normal `InputDevice` path
cannot see it. SDL3's HIDAPI Steam drivers speak Valve's protocol instead — over Bluetooth LE or
USB — keep lizard mode off and parse the reports. Only those drivers are enabled
(`SDL_HINT_JOYSTICK_HIDAPI=0`, `SDL_HINT_JOYSTICK_HIDAPI_STEAM=1`), so SDL never opens, claims or
requests USB permission for any other controller, and every non-Valve pad keeps the untouched
Android input path.

| Piece | What it does |
| --- | --- |
| `steamctrl/steam_controller_bridge.cpp` | JNI bridge over SDL3's gamepad API: flat int/float arrays for up to four Valve HIDAPI pads, plus rumble |
| `SteamControllerBackend` | ~250 Hz poll thread, frame coalescing onto the main thread, the 22-bit button model, trackpad-as-mouse and the back-paddle / "…" bindings |
| `WinHandler` SDL pads | Seats each pad in the ordinary player-slot machinery under a synthetic device id, shadows the Valve device Android also exposes, and routes rumble back through SDL |
| `ControllerTestPanel` | The animated pad picture: live button glow, movable stick nubs that glow with deflection, trigger and battery readouts, and the inputs-verified tally (18 on a Steam Controller) |
| `VisualControllerBinder` | Tap a button on the pad art to rebind it, written into the same profile store the list editor uses |
| `pad_*.png` | The per-family controller artwork (Xbox 360 / Xbox / DualSense / DualShock 4 / DualShock 3 / Switch Pro / 8BitDo / GameCube / SNES / Steam / Generic) |

Support is **off by default** and lives in *Settings → Input Controls → Steam Controller*. While
it is off, SDL is never loaded and the normal controller path is byte-for-byte unchanged.

Credit history — every commit below is The412Banner's, in
[The412Banner/Bannerlator](https://github.com/The412Banner/Bannerlator):

| Commit | Date | Subject |
| --- | --- | --- |
| `9ca9bcf` | 2026-08-31 | Controller Test: reuse the visualizer in Settings > Input Controls |
| `d32a03f` | 2026-08-31 | Controller Test: accurate per-family pad art + smaller in-game popup |
| `1678f23` | 2026-08-31 | Controller Test: bound the pad picture to a compact size |
| `4344ef4` | 2026-08-31 | Visual controller binder: tap the pad to remap, in Settings + in-game |
| `475721e` | 2026-08-31 | Controller pads: pre-rendered PNG art + overlay; Settings test-first; fix live-input arm |
| `8d2e1e3` | 2026-08-31 | Controller test: movable stick nubs + pinned footer (Identify never clips) |
| `90a8286` | 2026-08-31 | Controller test: stick nubs glow on deflection + landscape pad-left / metrics-right |
| `1e0d46f` | 2026-08-31 | Controller test: landscape metrics as a 2x2 grid so all four tiles fit |
| `9722956` | 2026-08-31 | feat(controller-test): independent OSC/physical profile lanes + live profile lists |
| `095269d` | 2026-08-31 | Bind screen: add Delete profile + outline/dividers on menus and popup |
| `b02d981` | 2026-08-31 | Controller test: unify popup-menu styling on outlinedMenuCard; rename Settings card |
| `b9e6552` | 2026-09-10 | Steam Controller support via SDL3 (opt-in, Input Controls > Device) |
| `e5b1272` | 2026-09-10 | Steam Controller: fetch pad name/path on the poll thread |
| `6d3b2a7` | 2026-09-10 | Steam Controller: back buttons, profile bindings, settings test dialog |
| `d03f6b1` | 2026-09-10 | Steam Controller: make the "…" (Quick Access) button mappable |
| `3d243e4` | 2026-09-10 | Controller test: count the Steam Controller's "…" button (18 inputs) |
| `b597711` | 2026-09-10 | Steam Controller: pick which trackpad moves the mouse (right / left / both / off) |
| `c2acbbd` | 2026-09-10 | Merge branch 'feat/steam-controller-sdl': opt-in Steam Controller support via SDL3 |

Bannerlator is GPL-3.0, the same license as WinNative, and remains so. SDL3 is Zlib-licensed and
is vendored unmodified; see [`vendor/maven/README.md`](vendor/maven/README.md) and
[`EMULATOR_CREDITS.md`](EMULATOR_CREDITS.md).

> Steam Controller support, controller test and visual binder by The412Banner
> (https://github.com/The412Banner/Bannerlator)

#### DirectAudio — what came from The412Banner's driver

WinNative's microphone support exists because **The412Banner** wrote
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ dependencies {

implementation 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1'
implementation 'com.android.ndk.thirdparty:curl:7.85.0-beta-1'
implementation 'org.libsdl.android:SDL3:3.4.16@aar'

implementation libs.playServicesGamesV2

Expand Down
3 changes: 3 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
native <methods>;
}

# SDL3 (Steam Controller): SDL's native side resolves these classes by name.
-keep class org.libsdl.app.** { *; }

# zstd-jni — the native side does GetFieldID("srcPos", "J") /
# GetFieldID("dstPos", "J") on ZstdInputStreamNoFinalizer and
# similar via JNI; R8 renames the Kotlin/Java fields and the lookup
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/app/PluviaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ class PluviaApp : Application() {
super.onCreate()
instance = this

val systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable)
if (systemUncaughtHandler != null) {
systemUncaughtHandler.uncaughtException(thread, throwable)
} else {
android.os.Process.killProcess(android.os.Process.myPid())
kotlin.system.exitProcess(10)
}
}

com.winlator.cmod.feature.retro.Ps2GameOverlay.install()
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,11 @@ add_library(waudio SHARED
)
target_compile_options(waudio PRIVATE -Wall -Wextra -fvisibility=hidden)
target_link_libraries(waudio log dl)

find_package(SDL3 REQUIRED CONFIG)

add_library(steamctrl SHARED
steamctrl/steam_controller_bridge.cpp
)
target_compile_options(steamctrl PRIVATE -Wall -Wextra -fvisibility=hidden)
target_link_libraries(steamctrl SDL3::SDL3 log)
237 changes: 237 additions & 0 deletions app/src/main/cpp/steamctrl/steam_controller_bridge.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#include <jni.h>
#include <android/log.h>
#include <mutex>

#define SDL_MAIN_HANDLED
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

#define TAG "SteamCtrlBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)

namespace {

constexpr int kMaxPads = 4;
constexpr Uint16 kValveVendorId = 0x28DE;

constexpr int I_ID = 0;
constexpr int I_BUTTONS = 1;
constexpr int I_STRIDE = 2;

constexpr int F_LX = 0, F_LY = 1, F_RX = 2, F_RY = 3;
constexpr int F_LT = 4, F_RT = 5;
constexpr int F_RPAD_DOWN = 6, F_RPAD_X = 7, F_RPAD_Y = 8;
constexpr int F_LPAD_DOWN = 9, F_LPAD_X = 10, F_LPAD_Y = 11;
constexpr int F_STRIDE = 12;

enum : int {
B_A = 0, B_B, B_X, B_Y, B_LB, B_RB, B_BACK, B_START, B_LSTICK, B_RSTICK, B_GUIDE,
B_DPAD_UP, B_DPAD_DOWN, B_DPAD_LEFT, B_DPAD_RIGHT,
B_QAM, B_R4, B_L4, B_R5, B_L5, B_RPAD_CLICK, B_LPAD_CLICK,
};

struct ButtonMap { SDL_GamepadButton sdl; int bit; };
constexpr ButtonMap kButtons[] = {
{ SDL_GAMEPAD_BUTTON_SOUTH, B_A },
{ SDL_GAMEPAD_BUTTON_EAST, B_B },
{ SDL_GAMEPAD_BUTTON_WEST, B_X },
{ SDL_GAMEPAD_BUTTON_NORTH, B_Y },
{ SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, B_LB },
{ SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, B_RB },
{ SDL_GAMEPAD_BUTTON_BACK, B_BACK },
{ SDL_GAMEPAD_BUTTON_START, B_START },
{ SDL_GAMEPAD_BUTTON_LEFT_STICK, B_LSTICK },
{ SDL_GAMEPAD_BUTTON_RIGHT_STICK, B_RSTICK },
{ SDL_GAMEPAD_BUTTON_GUIDE, B_GUIDE },
{ SDL_GAMEPAD_BUTTON_DPAD_UP, B_DPAD_UP },
{ SDL_GAMEPAD_BUTTON_DPAD_DOWN, B_DPAD_DOWN },
{ SDL_GAMEPAD_BUTTON_DPAD_LEFT, B_DPAD_LEFT },
{ SDL_GAMEPAD_BUTTON_DPAD_RIGHT, B_DPAD_RIGHT },
{ SDL_GAMEPAD_BUTTON_MISC1, B_QAM },
{ SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, B_R4 },
{ SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, B_L4 },
{ SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, B_R5 },
{ SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, B_L5 },
{ SDL_GAMEPAD_BUTTON_MISC2, B_RPAD_CLICK },
{ SDL_GAMEPAD_BUTTON_TOUCHPAD, B_LPAD_CLICK },
};

struct Pad {
SDL_JoystickID id;
SDL_Gamepad *gamepad;
};

std::mutex g_lock;
Pad g_pads[kMaxPads];
int g_numPads = 0;
bool g_initialized = false;

bool isValveHidapiPad(SDL_JoystickID id) {
if (SDL_GetGamepadVendorForID(id) != kValveVendorId) return false;
return SDL_GetGamepadGUIDForID(id).data[14] == 'h';
}
float stickAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) {
float v = SDL_GetGamepadAxis(gamepad, axis) / 32767.0f;
return v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v);
}
float triggerAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) {
float v = SDL_GetGamepadAxis(gamepad, axis) / 32767.0f;
return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v);
}
void readTouchpad(SDL_Gamepad *gamepad, int touchpad, float *out) {
bool down = false;
float x = 0.0f, y = 0.0f, pressure = 0.0f;
if (touchpad < SDL_GetNumGamepadTouchpads(gamepad)
&& SDL_GetGamepadTouchpadFinger(gamepad, touchpad, 0, &down, &x, &y, &pressure)) {
out[0] = down ? 1.0f : 0.0f;
out[1] = x;
out[2] = y;
} else {
out[0] = out[1] = out[2] = 0.0f;
}
}
void syncPadsLocked() {
int count = 0;
SDL_JoystickID *ids = SDL_GetGamepads(&count);

for (int i = 0; i < g_numPads;) {
bool present = false;
for (int j = 0; j < count; j++) {
if (ids[j] == g_pads[i].id) { present = true; break; }
}
if (present && SDL_GamepadConnected(g_pads[i].gamepad)) {
i++;
continue;
}
LOGI("Steam controller %d disconnected", (int)g_pads[i].id);
SDL_CloseGamepad(g_pads[i].gamepad);
g_pads[i] = g_pads[--g_numPads];
}

for (int j = 0; j < count && g_numPads < kMaxPads; j++) {
SDL_JoystickID id = ids[j];
bool known = false;
for (int i = 0; i < g_numPads; i++) {
if (g_pads[i].id == id) { known = true; break; }
}
if (known || !isValveHidapiPad(id)) continue;
SDL_Gamepad *gamepad = SDL_OpenGamepad(id);
if (!gamepad) {
LOGW("SDL_OpenGamepad(%d) failed: %s", (int)id, SDL_GetError());
continue;
}
g_pads[g_numPads++] = { id, gamepad };
LOGI("Steam controller %d connected: %s (pid %04x, touchpads %d, path %s)", (int)id,
SDL_GetGamepadNameForID(id), SDL_GetGamepadProductForID(id),
SDL_GetNumGamepadTouchpads(gamepad), SDL_GetGamepadPathForID(id));
}

SDL_free(ids);
}
Pad *findPadLocked(SDL_JoystickID id) {
for (int i = 0; i < g_numPads; i++) {
if (g_pads[i].id == id) return &g_pads[i];
}
return nullptr;
}
}
extern "C" {

JNIEXPORT jboolean JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeInit(JNIEnv *, jclass, jboolean bluetooth) {
std::lock_guard<std::mutex> guard(g_lock);
if (g_initialized) return JNI_TRUE;

SDL_SetMainReady();
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "0");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, bluetooth ? "1" : "0");
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
SDL_SetHint(SDL_HINT_TV_REMOTE_AS_JOYSTICK, "0");

if (!SDL_Init(SDL_INIT_GAMEPAD)) {
LOGW("SDL_Init(GAMEPAD) failed: %s", SDL_GetError());
return JNI_FALSE;
}
if (!bluetooth) SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1");

SDL_SetJoystickEventsEnabled(false);
SDL_SetGamepadEventsEnabled(false);

g_initialized = true;
int v = SDL_GetVersion();
LOGI("SDL %d.%d.%d up: HIDAPI Steam drivers only (bluetooth %s)", SDL_VERSIONNUM_MAJOR(v),
SDL_VERSIONNUM_MINOR(v), SDL_VERSIONNUM_MICRO(v), bluetooth ? "on" : "off");
return JNI_TRUE;
}
JNIEXPORT jint JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativePoll(JNIEnv *env, jclass, jintArray ints, jfloatArray floats) {
std::lock_guard<std::mutex> guard(g_lock);
if (!g_initialized) return -1;

SDL_UpdateGamepads();
SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST);
syncPadsLocked();

jint iv[kMaxPads * I_STRIDE] = {};
jfloat fv[kMaxPads * F_STRIDE] = {};
for (int p = 0; p < g_numPads; p++) {
SDL_Gamepad *gamepad = g_pads[p].gamepad;
jint *pi = iv + p * I_STRIDE;
jfloat *pf = fv + p * F_STRIDE;

int buttons = 0;
for (const ButtonMap &m : kButtons) {
if (SDL_GetGamepadButton(gamepad, m.sdl)) buttons |= 1 << m.bit;
}
pi[I_ID] = (jint)g_pads[p].id;
pi[I_BUTTONS] = buttons;

pf[F_LX] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX);
pf[F_LY] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY);
pf[F_RX] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX);
pf[F_RY] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY);
pf[F_LT] = triggerAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER);
pf[F_RT] = triggerAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER);
readTouchpad(gamepad, 1, pf + F_RPAD_DOWN);
readTouchpad(gamepad, 0, pf + F_LPAD_DOWN);
}

if (g_numPads > 0) {
env->SetIntArrayRegion(ints, 0, g_numPads * I_STRIDE, iv);
env->SetFloatArrayRegion(floats, 0, g_numPads * F_STRIDE, fv);
}
return g_numPads;
}
JNIEXPORT jstring JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeGetName(JNIEnv *env, jclass, jint id) {
std::lock_guard<std::mutex> guard(g_lock);
const char *name = g_initialized ? SDL_GetGamepadNameForID((SDL_JoystickID)id) : nullptr;
return env->NewStringUTF(name ? name : "Steam Controller");
}
JNIEXPORT jstring JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeGetPath(JNIEnv *env, jclass, jint id) {
std::lock_guard<std::mutex> guard(g_lock);
const char *path = g_initialized ? SDL_GetGamepadPathForID((SDL_JoystickID)id) : nullptr;
return path ? env->NewStringUTF(path) : nullptr;
}
JNIEXPORT void JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeRumble(JNIEnv *, jclass, jint id, jint low, jint high, jint durationMs) {
std::lock_guard<std::mutex> guard(g_lock);
if (!g_initialized) return;
Pad *pad = findPadLocked((SDL_JoystickID)id);
if (!pad) return;
auto clamp16 = [](jint v) -> Uint16 { return (Uint16)(v < 0 ? 0 : (v > 0xFFFF ? 0xFFFF : v)); };
SDL_RumbleGamepad(pad->gamepad, clamp16(low), clamp16(high), (Uint32)(durationMs < 0 ? 0 : durationMs));
}
JNIEXPORT void JNICALL
Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeShutdown(JNIEnv *, jclass) {
std::lock_guard<std::mutex> guard(g_lock);
if (!g_initialized) return;
for (int i = 0; i < g_numPads; i++) SDL_CloseGamepad(g_pads[i].gamepad);
g_numPads = 0;
SDL_Quit();
g_initialized = false;
LOGI("SDL shut down");
}
}
Loading
Loading