From 02c37e848ddcfd36e7856d0fe45dba1ffd479ad3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:04:09 +0200 Subject: [PATCH 1/6] Steam Machine and SteamOS support: gamepads, game services, Steam depots - Generic controller and game-services API in com.codename1.gaming (Gamepad, Gamepads, GameServices); a controller drives focus on any form. - Linux port: evdev gamepads with rumble, fullscreen, Steamworks through the flat API loaded at run time (achievements, stats, overlay, cloud saves, floating keyboard). No Steamworks file is committed. - Runs in the Steam Linux Runtime: no direct libGLESv2 link, OpenSSL 1.1 fallback for crypto. - Windows: Xbox controller input feeds the same API. - Steam depot packaging (steam.* hints), cn1:buildSteamLocal, cn1:buildSteam, cn1:steamUpload and the Gradle tasks; Steam chapter in the developer guide. - CI runs the Linux demo inside the Steam runtime image. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/linux-build-run.yml | 16 + .../src/com/codename1/gaming/GameInput.java | 62 +- .../com/codename1/gaming/GameServices.java | 232 ++++++++ .../codename1/gaming/GameServicesBackend.java | 134 +++++ .../gaming/GameServicesListener.java | 30 + .../src/com/codename1/gaming/Gamepad.java | 228 ++++++++ .../com/codename1/gaming/GamepadListener.java | 52 ++ .../src/com/codename1/gaming/Gamepads.java | 310 ++++++++++ .../impl/CodenameOneImplementation.java | 5 + CodenameOne/src/com/codename1/ui/Display.java | 18 + Ports/LinuxPort/nativeSources/cn1_linux.h | 12 +- .../nativeSources/cn1_linux_crypto.c | 106 +++- .../nativeSources/cn1_linux_events.c | 8 + .../nativeSources/cn1_linux_gamepad.c | 505 +++++++++++++++++ .../LinuxPort/nativeSources/cn1_linux_steam.c | 531 ++++++++++++++++++ .../nativeSources/cn1_linux_window.c | 11 + .../impl/linux/LinuxGameServices.java | 237 ++++++++ .../impl/linux/LinuxImplementation.java | 40 ++ .../com/codename1/impl/linux/LinuxNative.java | 92 +++ .../impl/windows/WindowsImplementation.java | 41 ++ docs/developer-guide/Gaming-Consoles.asciidoc | 5 + docs/developer-guide/Steam.asciidoc | 175 ++++++ docs/developer-guide/developer-guide.asciidoc | 2 + .../builders/LinuxNativeBuilder.java | 29 + .../builders/SteamDepotPackager.java | 303 ++++++++++ .../java/com/codename1/maven/AppBuilder.java | 43 +- .../builders/SteamDepotPackagerTest.java | 120 ++++ .../build/shared/BuildHintsDesktop.java | 57 ++ .../com/codename1/build/shared/HintGroup.java | 1 + .../java/com/codename1/gradle/AppSupport.java | 13 + .../com/codename1/maven/SteamUploadMojo.java | 129 +++++ .../AbstractBuildWrapperMojo.java | 5 + .../buildWrappers/BuildSteamLocalMojo.java | 47 ++ .../maven/buildWrappers/BuildSteamMojo.java | 52 ++ .../codename1/gaming/GameServicesTest.java | 123 ++++ .../com/codename1/gaming/GamepadsTest.java | 195 +++++++ scripts/ci/steam-runtime-smoke.sh | 53 ++ .../tools/translator/ByteCodeTranslator.java | 11 +- 38 files changed, 4015 insertions(+), 18 deletions(-) create mode 100644 CodenameOne/src/com/codename1/gaming/GameServices.java create mode 100644 CodenameOne/src/com/codename1/gaming/GameServicesBackend.java create mode 100644 CodenameOne/src/com/codename1/gaming/GameServicesListener.java create mode 100644 CodenameOne/src/com/codename1/gaming/Gamepad.java create mode 100644 CodenameOne/src/com/codename1/gaming/GamepadListener.java create mode 100644 CodenameOne/src/com/codename1/gaming/Gamepads.java create mode 100644 Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c create mode 100644 Ports/LinuxPort/nativeSources/cn1_linux_steam.c create mode 100644 Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java create mode 100644 docs/developer-guide/Steam.asciidoc create mode 100644 maven/build-engine/src/main/java/com/codename1/builders/SteamDepotPackager.java create mode 100644 maven/build-engine/src/test/java/com/codename1/builders/SteamDepotPackagerTest.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SteamUploadMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamLocalMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamMojo.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java create mode 100755 scripts/ci/steam-runtime-smoke.sh diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 43b2cf4e14a..f6d62e507f6 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -49,6 +49,7 @@ on: # it, the same way archetype-smoke, designer and pr.yml list them. - 'scripts/ci/apt-get-install.sh' - 'scripts/ci/apt-get-update.sh' + - 'scripts/ci/steam-runtime-smoke.sh' - 'scripts/test_gc_heap_removal.py' - 'vm/**' - 'Ports/LinuxPort/**' @@ -86,6 +87,7 @@ on: - '.github/workflows/linux-build-run.yml' - 'scripts/ci/apt-get-install.sh' - 'scripts/ci/apt-get-update.sh' + - 'scripts/ci/steam-runtime-smoke.sh' - 'scripts/test_gc_heap_removal.py' - 'vm/**' - 'Ports/LinuxPort/**' @@ -549,6 +551,20 @@ jobs: # A runnable, self-contained windowed demo binary for this arch (opens a # GTK window showing a Form -- not the headless suite). Download and run it # on a Linux desktop to smoke-test the native port on real hardware. + # Steam launches Linux games inside the Steam Linux Runtime ("sniper"), whose + # libraries are not the runner's. The demo is this suite's own objects linked + # as a windowed app, so a DT_NEEDED the runtime lacks, or a crash at start-up, + # fails here rather than on a player's Steam Deck. + - name: Run in the Steam Linux Runtime (x64) + if: ${{ !cancelled() && matrix.arch == 'x64' }} + run: | + elf="artifacts/linux-port/demo/codenameone-linux-demo-x64" + if [ ! -f "$elf" ]; then + echo "::warning::no windowed demo was linked; the Steam Linux Runtime smoke did not run" + exit 0 + fi + bash scripts/ci/steam-runtime-smoke.sh "$elf" 15 + - name: Upload windowed demo binary (${{ matrix.arch }}) if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 diff --git a/CodenameOne/src/com/codename1/gaming/GameInput.java b/CodenameOne/src/com/codename1/gaming/GameInput.java index 1e90dcd3c2c..efc0aaf5d78 100644 --- a/CodenameOne/src/com/codename1/gaming/GameInput.java +++ b/CodenameOne/src/com/codename1/gaming/GameInput.java @@ -44,6 +44,11 @@ /// frame in which the transition happened. Edges are cleared by the `GameView` /// at the end of every frame, after `GameView#update(double)` has run. /// +/// Game controllers (see `Gamepads`) are folded in: `#isGamepadButtonDown(int)` +/// and `#wasGamepadButtonPressed(int)` answer for any connected controller, and +/// `#getAxisX()` / `#getAxisY()` fall back to the first controller's left stick +/// when no on-screen joystick is held. +/// /// All state is written and read on the Codename One EDT, so no synchronization is /// required. public class GameInput { @@ -60,6 +65,10 @@ public class GameInput { private float axisX; private float axisY; + // Which gamepad buttons were down (on any controller) at the end of the + // previous frame, for the gamepad edge queries. + private final boolean[] padDownLastFrame = new boolean[Gamepad.BUTTON_COUNT]; + /// Package private -- only `GameView` constructs the input. GameInput() { } @@ -103,6 +112,38 @@ void clearFrameEdges() { releasedEdge.clear(); pointerPressedEdge = false; pointerReleasedEdge = false; + for (int b = 0; b < padDownLastFrame.length; b++) { + padDownLastFrame[b] = isGamepadButtonDown(b); + } + } + + /// Returns true while the button is held on any connected controller. + /// + /// #### Parameters + /// + /// - `button`: one of the `Gamepad.BUTTON_*` constants + public boolean isGamepadButtonDown(int button) { + Gamepad[] pads = Gamepads.getGamepads(); + for (Gamepad pad : pads) { + if (pad.isButtonDown(button)) { + return true; + } + } + return false; + } + + /// Returns true during the first frame in which the button is held on any + /// connected controller. + public boolean wasGamepadButtonPressed(int button) { + return button >= 0 && button < padDownLastFrame.length + && !padDownLastFrame[button] && isGamepadButtonDown(button); + } + + /// Returns true during the first frame after the button was released on + /// every connected controller. + public boolean wasGamepadButtonReleased(int button) { + return button >= 0 && button < padDownLastFrame.length + && padDownLastFrame[button] && !isGamepadButtonDown(button); } /// Returns true while the given raw key code is held down. @@ -173,14 +214,27 @@ public boolean wasPointerReleased() { } /// The horizontal analog value, -1 (left) to 1 (right), from an on-screen - /// `VirtualJoystick`. 0 when there is no joystick or it is centered. + /// `VirtualJoystick`, or else the first controller's left stick. 0 when + /// neither is deflected. public float getAxisX() { - return axisX; + if (axisX != 0 || axisY != 0) { + return axisX; + } + return firstPadAxis(Gamepad.AXIS_LEFT_X); } /// The vertical analog value, -1 (up) to 1 (down the screen), from an on-screen - /// `VirtualJoystick`. 0 when there is no joystick or it is centered. + /// `VirtualJoystick`, or else the first controller's left stick. 0 when + /// neither is deflected. public float getAxisY() { - return axisY; + if (axisX != 0 || axisY != 0) { + return axisY; + } + return firstPadAxis(Gamepad.AXIS_LEFT_Y); + } + + private static float firstPadAxis(int axis) { + Gamepad[] pads = Gamepads.getGamepads(); + return pads.length == 0 ? 0 : pads[0].getAxis(axis); } } diff --git a/CodenameOne/src/com/codename1/gaming/GameServices.java b/CodenameOne/src/com/codename1/gaming/GameServices.java new file mode 100644 index 00000000000..46ec94d9044 --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/GameServices.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +import java.util.ArrayList; + +/// The gaming platform the application runs under: achievements, stats, cloud +/// saves, the signed in player and their presence. +/// +/// One API for every platform. The first backend is Steam (the `steam` build +/// target); others plug in behind the same calls. Everything here is safe to +/// call everywhere: with no platform, or one that has not connected, +/// `#isAvailable()` is false and every call does nothing and answers its +/// default, so a game needs no platform checks around it. +/// +/// ```java +/// GameServices.unlockAchievement("FIRST_WIN"); +/// GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); +/// GameServices.commit(); +/// ``` +/// +/// Identifiers are the ones configured with the platform (on Steam, the API +/// names entered in the Steamworks partner site). +/// +/// **Pausing.** Register a `GameServicesListener` to hear when the platform's +/// own interface (the Steam overlay) opens over the game; pause while it is up. +/// +/// Call from the EDT. +public final class GameServices { + private static GameServicesBackend backend; + private static final ArrayList LISTENERS = new ArrayList(); + + private GameServices() { + } + + /// Installs the platform backend. Called by ports. + public static void setBackend(GameServicesBackend b) { + backend = b; + } + + /// The platform name (`"steam"`), or an empty string when there is none. + public static String getPlatformName() { + return backend == null ? "" : backend.getName(); + } + + /// True when a gaming platform is connected and a player signed in. + public static boolean isAvailable() { + return backend != null && backend.isAvailable(); + } + + private static GameServicesBackend live() { + return backend != null && backend.isAvailable() ? backend : null; + } + + /// The player's stable platform id (a Steam id, for instance), or an empty + /// string. + public static String getPlayerId() { + GameServicesBackend b = live(); + return b == null ? "" : b.getPlayerId(); + } + + /// The player's display name, or an empty string. + public static String getPlayerName() { + GameServicesBackend b = live(); + return b == null ? "" : b.getPlayerName(); + } + + /// Unlocks an achievement. Platforms show their own notification. Persisted + /// by `#commit()`. + public static boolean unlockAchievement(String id) { + GameServicesBackend b = live(); + return b != null && id != null && b.setAchievement(id, true); + } + + /// Locks an achievement again, for testing; platforms may refuse it outside + /// development builds. + public static boolean resetAchievement(String id) { + GameServicesBackend b = live(); + return b != null && id != null && b.setAchievement(id, false); + } + + /// Whether an achievement is unlocked. + public static boolean isAchievementUnlocked(String id) { + GameServicesBackend b = live(); + return b != null && id != null && b.isAchievementUnlocked(id); + } + + /// An integer stat, or `def` when unknown. + public static int getStatInt(String id, int def) { + GameServicesBackend b = live(); + return b == null || id == null ? def : b.getStatInt(id, def); + } + + /// Sets an integer stat. Persisted by `#commit()`. + public static boolean setStat(String id, int value) { + GameServicesBackend b = live(); + return b != null && id != null && b.setStatInt(id, value); + } + + /// A float stat, or `def` when unknown. + public static float getStatFloat(String id, float def) { + GameServicesBackend b = live(); + return b == null || id == null ? def : b.getStatFloat(id, def); + } + + /// Sets a float stat. Persisted by `#commit()`. + public static boolean setStat(String id, float value) { + GameServicesBackend b = live(); + return b != null && id != null && b.setStatFloat(id, value); + } + + /// Sends achievement and stat changes to the platform. Call it at natural + /// points (end of a level), not every frame: platforms rate limit it. + public static boolean commit() { + GameServicesBackend b = live(); + return b != null && b.commit(); + } + + /// Whether saves are synced through the platform for this player. + public static boolean isCloudSaveAvailable() { + GameServicesBackend b = live(); + return b != null && b.isCloudSaveAvailable(); + } + + /// Writes a cloud save, replacing one with the same name. + public static boolean writeSave(String name, byte[] data) { + GameServicesBackend b = live(); + return b != null && name != null && data != null && b.writeSave(name, data); + } + + /// Reads a cloud save, or null when there is none. + public static byte[] readSave(String name) { + GameServicesBackend b = live(); + return b == null || name == null ? null : b.readSave(name); + } + + /// Whether a cloud save exists. + public static boolean hasSave(String name) { + GameServicesBackend b = live(); + return b != null && name != null && b.hasSave(name); + } + + /// Deletes a cloud save. + public static boolean deleteSave(String name) { + GameServicesBackend b = live(); + return b != null && name != null && b.deleteSave(name); + } + + /// Sets what friends see the player doing. On Steam `key` is a rich + /// presence key (`"status"` is shown as is). + public static boolean setPresence(String key, String value) { + GameServicesBackend b = live(); + return b != null && key != null && b.setPresence(key, value); + } + + /// Clears every presence value. + public static boolean clearPresence() { + GameServicesBackend b = live(); + return b != null && b.setPresence(null, null); + } + + /// Whether the player owns an add-on (on Steam, a DLC app id). + public static boolean ownsContent(String id) { + GameServicesBackend b = live(); + return b != null && id != null && b.ownsContent(id); + } + + /// Opens the platform's own interface over the game. `target` names a page: + /// on Steam one of the overlay dialogs (`"friends"`, `"achievements"`, + /// `"stats"`, `"community"`, `"settings"`) or an `https://` URL. Answers + /// false when the platform has no such interface. + public static boolean openPlatformUI(String target) { + GameServicesBackend b = live(); + return b != null && target != null && b.openPlatformUI(target); + } + + /// True when the player is on a controller-first interface (a Steam Deck, + /// Steam's Big Picture / Gaming Mode): size touch targets and text for it and + /// expect no mouse. + public static boolean isControllerFirst() { + GameServicesBackend b = live(); + return b != null && b.isControllerFirst(); + } + + /// The language the player chose for the game on the platform, as the + /// platform names it (Steam: `"english"`, `"german"`...), or an empty string. + public static String getLanguage() { + GameServicesBackend b = live(); + return b == null ? "" : b.getLanguage(); + } + + /// Registers a listener. + public static void addListener(GameServicesListener l) { + if (l != null && !LISTENERS.contains(l)) { + LISTENERS.add(l); + } + } + + /// Unregisters a listener. + public static void removeListener(GameServicesListener l) { + LISTENERS.remove(l); + } + + /// Tells listeners the platform's interface opened or closed. Called by + /// backends, on the EDT. + public static void firePlatformUIChanged(boolean shown) { + Object[] ls = LISTENERS.toArray(); + for (Object l : ls) { + ((GameServicesListener) l).platformUIChanged(shown); + } + } +} diff --git a/CodenameOne/src/com/codename1/gaming/GameServicesBackend.java b/CodenameOne/src/com/codename1/gaming/GameServicesBackend.java new file mode 100644 index 00000000000..0a7af64acc2 --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/GameServicesBackend.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +/// The platform side of `GameServices`: Steam, and later Game Center, Google +/// Play Games or Xbox Live. A port installs one with +/// `GameServices#setBackend(GameServicesBackend)`; applications never use this +/// class. Every method has a do-nothing default, so a backend overrides only +/// what its platform has. +/// +/// A backend reports the platform's own interface opening over the game with +/// `GameServices#firePlatformUIChanged(boolean)`. +public abstract class GameServicesBackend { + /// A short name for the platform, such as `"steam"`. + public abstract String getName(); + + /// True once the platform accepted the connection and a player is signed in. + public abstract boolean isAvailable(); + + /// See `GameServices#getPlayerId()`. + public String getPlayerId() { + return ""; + } + + /// See `GameServices#getPlayerName()`. + public String getPlayerName() { + return ""; + } + + /// See `GameServices#unlockAchievement(String)`. + public boolean setAchievement(String id, boolean unlocked) { + return false; + } + + /// See `GameServices#isAchievementUnlocked(String)`. + public boolean isAchievementUnlocked(String id) { + return false; + } + + /// See `GameServices#getStatInt(String, int)`. + public int getStatInt(String id, int def) { + return def; + } + + /// See `GameServices#setStat(String, int)`. + public boolean setStatInt(String id, int value) { + return false; + } + + /// See `GameServices#getStatFloat(String, float)`. + public float getStatFloat(String id, float def) { + return def; + } + + /// See `GameServices#setStat(String, float)`. + public boolean setStatFloat(String id, float value) { + return false; + } + + /// See `GameServices#commit()`. + public boolean commit() { + return false; + } + + /// See `GameServices#isCloudSaveAvailable()`. + public boolean isCloudSaveAvailable() { + return false; + } + + /// See `GameServices#writeSave(String, byte[])`. + public boolean writeSave(String name, byte[] data) { + return false; + } + + /// See `GameServices#readSave(String)`. + public byte[] readSave(String name) { + return null; + } + + /// See `GameServices#hasSave(String)`. + public boolean hasSave(String name) { + return false; + } + + /// See `GameServices#deleteSave(String)`. + public boolean deleteSave(String name) { + return false; + } + + /// See `GameServices#setPresence(String, String)`; a null key clears all. + public boolean setPresence(String key, String value) { + return false; + } + + /// See `GameServices#ownsContent(String)`. + public boolean ownsContent(String id) { + return false; + } + + /// See `GameServices#openPlatformUI(String)`. + public boolean openPlatformUI(String target) { + return false; + } + + /// See `GameServices#isControllerFirst()`. + public boolean isControllerFirst() { + return false; + } + + /// See `GameServices#getLanguage()`. + public String getLanguage() { + return ""; + } +} diff --git a/CodenameOne/src/com/codename1/gaming/GameServicesListener.java b/CodenameOne/src/com/codename1/gaming/GameServicesListener.java new file mode 100644 index 00000000000..a7c1918e2d3 --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/GameServicesListener.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +/// Told when the gaming platform's own interface (the Steam overlay) opens over +/// the game or closes. Register with `GameServices#addListener`; called on the EDT. +public interface GameServicesListener { + /// The platform interface opened (`shown` true) or closed. Pause while open. + void platformUIChanged(boolean shown); +} diff --git a/CodenameOne/src/com/codename1/gaming/Gamepad.java b/CodenameOne/src/com/codename1/gaming/Gamepad.java new file mode 100644 index 00000000000..6102fe2e6bf --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/Gamepad.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +/// One connected game controller. +/// +/// Buttons and axes use the standard layout every modern controller is mapped +/// to -- the Xbox layout, which is also what Steam Input presents for a Steam +/// Deck, a Steam Controller or a PlayStation pad. Query it with +/// `#isButtonDown(int)` and `#getAxis(int)`, or be told about changes through a +/// `GamepadListener` registered with `Gamepads`. +/// +/// Instances are owned by `Gamepads` and live for as long as the controller is +/// connected; `#isConnected()` turns false once it is gone. All state is written +/// and read on the EDT. +public final class Gamepad { + /// The bottom face button (A on Xbox, Cross on PlayStation). + public static final int BUTTON_A = 0; + /// The right face button (B on Xbox, Circle on PlayStation). + public static final int BUTTON_B = 1; + /// The left face button (X on Xbox, Square on PlayStation). + public static final int BUTTON_X = 2; + /// The top face button (Y on Xbox, Triangle on PlayStation). + public static final int BUTTON_Y = 3; + /// The left shoulder button. + public static final int BUTTON_LEFT_SHOULDER = 4; + /// The right shoulder button. + public static final int BUTTON_RIGHT_SHOULDER = 5; + /// The back / view / select button. + public static final int BUTTON_BACK = 6; + /// The start / menu button. + public static final int BUTTON_START = 7; + /// The guide / home button. Steam keeps this one for itself while a game runs + /// under it, so do not rely on receiving it. + public static final int BUTTON_GUIDE = 8; + /// Pressing the left stick. + public static final int BUTTON_LEFT_STICK = 9; + /// Pressing the right stick. + public static final int BUTTON_RIGHT_STICK = 10; + /// D-pad up. + public static final int BUTTON_DPAD_UP = 11; + /// D-pad down. + public static final int BUTTON_DPAD_DOWN = 12; + /// D-pad left. + public static final int BUTTON_DPAD_LEFT = 13; + /// D-pad right. + public static final int BUTTON_DPAD_RIGHT = 14; + /// The left trigger, reported as a button once pulled past halfway. + public static final int BUTTON_LEFT_TRIGGER = 15; + /// The right trigger, reported as a button once pulled past halfway. + public static final int BUTTON_RIGHT_TRIGGER = 16; + /// The number of buttons. + public static final int BUTTON_COUNT = 17; + + /// The left stick, -1 (left) to 1 (right). + public static final int AXIS_LEFT_X = 0; + /// The left stick, -1 (up) to 1 (down). + public static final int AXIS_LEFT_Y = 1; + /// The right stick, -1 (left) to 1 (right). + public static final int AXIS_RIGHT_X = 2; + /// The right stick, -1 (up) to 1 (down). + public static final int AXIS_RIGHT_Y = 3; + /// The left trigger, 0 (released) to 1 (fully pulled). + public static final int AXIS_LEFT_TRIGGER = 4; + /// The right trigger, 0 (released) to 1 (fully pulled). + public static final int AXIS_RIGHT_TRIGGER = 5; + /// The number of axes. + public static final int AXIS_COUNT = 6; + + private final int index; + private final String name; + private final boolean[] buttons = new boolean[BUTTON_COUNT]; + private final float[] axes = new float[AXIS_COUNT]; + private boolean connected = true; + private float deadZone = 0.15f; + + Gamepad(int index, String name) { + this.index = index; + this.name = name == null ? "" : name; + } + + /// The slot this controller occupies, 0 for the first one connected. A slot + /// freed by a disconnect is reused by the next controller. + public int getIndex() { + return index; + } + + /// The name the device reports, for display only; it may be empty. + public String getName() { + return name; + } + + /// False once the controller has been disconnected. + public boolean isConnected() { + return connected; + } + + void setConnected(boolean connected) { + this.connected = connected; + } + + /// True while the button is held. + /// + /// #### Parameters + /// + /// - `button`: one of the `BUTTON_*` constants + public boolean isButtonDown(int button) { + return button >= 0 && button < BUTTON_COUNT && buttons[button]; + } + + /// The axis value with the dead zone applied: a stick inside the dead zone + /// reads exactly 0, and the remaining travel is rescaled so the value still + /// reaches 1 at the edge. Triggers are not subject to the dead zone. + /// + /// #### Parameters + /// + /// - `axis`: one of the `AXIS_*` constants + public float getAxis(int axis) { + if (axis < 0 || axis >= AXIS_COUNT) { + return 0; + } + float v = axes[axis]; + if (axis == AXIS_LEFT_TRIGGER || axis == AXIS_RIGHT_TRIGGER) { + return v; + } + return applyDeadZone(v, deadZone); + } + + /// The axis value exactly as the device reported it, with no dead zone. + public float getRawAxis(int axis) { + if (axis < 0 || axis >= AXIS_COUNT) { + return 0; + } + return axes[axis]; + } + + /// The stick dead zone, 0.15 by default. + public float getDeadZone() { + return deadZone; + } + + /// Sets the stick dead zone, clamped to 0 .. 0.9. Worn sticks rest a little + /// off center; the dead zone stops that from reading as movement. + public void setDeadZone(float deadZone) { + if (deadZone < 0) { + deadZone = 0; + } else if (deadZone > 0.9f) { + deadZone = 0.9f; + } + this.deadZone = deadZone; + } + + /// Rumbles the controller, on ports and controllers that support it; it is + /// silently ignored everywhere else. + /// + /// #### Parameters + /// + /// - `strong`: the low frequency (heavy) motor, 0 to 1 + /// + /// - `weak`: the high frequency (light) motor, 0 to 1 + /// + /// - `durationMillis`: how long the rumble lasts + public void rumble(float strong, float weak, int durationMillis) { + if (connected) { + com.codename1.ui.Display.getInstance().rumbleGamepad(index, + clamp01(strong), clamp01(weak), durationMillis); + } + } + + boolean setButton(int button, boolean down) { + if (button < 0 || button >= BUTTON_COUNT || buttons[button] == down) { + return false; + } + buttons[button] = down; + return true; + } + + void setAxis(int axis, float value) { + if (axis < 0 || axis >= AXIS_COUNT) { + return; + } + if (value > 1) { + value = 1; + } else if (value < -1) { + value = -1; + } + axes[axis] = value; + } + + static float applyDeadZone(float v, float deadZone) { + float mag = v < 0 ? -v : v; + if (mag <= deadZone) { + return 0; + } + float scaled = (mag - deadZone) / (1 - deadZone); + if (scaled > 1) { + scaled = 1; + } + return v < 0 ? -scaled : scaled; + } + + private static float clamp01(float v) { + if (v < 0) { + return 0; + } + return v > 1 ? 1 : v; + } +} diff --git a/CodenameOne/src/com/codename1/gaming/GamepadListener.java b/CodenameOne/src/com/codename1/gaming/GamepadListener.java new file mode 100644 index 00000000000..6b62c9a5a8c --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/GamepadListener.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +/// Told about controllers coming and going and about their buttons. Register one +/// with `Gamepads#addListener(GamepadListener)`; every callback arrives on the EDT. +/// +/// Axis movement is not reported here, because a stick reports dozens of values +/// a second: read `Gamepad#getAxis(int)` from the update loop instead. +public interface GamepadListener { + /// A controller was connected. + void gamepadConnected(Gamepad pad); + + /// A controller was disconnected; `Gamepad#isConnected()` is already false. + void gamepadDisconnected(Gamepad pad); + + /// A button went down or up. + /// + /// #### Parameters + /// + /// - `pad`: the controller + /// + /// - `button`: one of the `Gamepad.BUTTON_*` constants + /// + /// - `down`: true when pressed, false when released + /// + /// #### Returns + /// + /// true to consume the event, which stops `Gamepads` from also turning it + /// into focus navigation + boolean gamepadButton(Gamepad pad, int button, boolean down); +} diff --git a/CodenameOne/src/com/codename1/gaming/Gamepads.java b/CodenameOne/src/com/codename1/gaming/Gamepads.java new file mode 100644 index 00000000000..ee20bb7c172 --- /dev/null +++ b/CodenameOne/src/com/codename1/gaming/Gamepads.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gaming; + +import com.codename1.ui.Command; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import java.util.ArrayList; + +/// The connected game controllers. +/// +/// Ports that read controllers report them here; applications ask for them with +/// `#getGamepads()` / `#getGamepad(int)` and listen with +/// `#addListener(GamepadListener)`. Where no port reports any controller the +/// list is simply empty. +/// +/// **Focus navigation.** Unless turned off with `#setFocusNavigation(boolean)`, a +/// button no listener consumed drives the user interface the way the keyboard +/// does: the D-pad and the left stick move focus, A presses the focused +/// component and B goes back. An ordinary form is therefore usable from a couch +/// with no code written for it. +/// +/// Everything here runs on the EDT. Ports call the `report*` methods from it. +public final class Gamepads { + private static final ArrayList PADS = new ArrayList(); + private static final ArrayList LISTENERS = new ArrayList(); + private static boolean focusNavigation = true; + + // Stick-as-D-pad hysteresis: press past PRESS, release under RELEASE, so a + // stick resting near the threshold does not chatter. + static final float STICK_PRESS = 0.6f; + static final float STICK_RELEASE = 0.4f; + + // Which direction each pad's left stick currently holds, as a D-pad button + // or -1, one entry per axis (x, y), indexed by pad slot. + private static int[] stickX = new int[0]; + private static int[] stickY = new int[0]; + + private Gamepads() { + } + + /// The connected controllers, in slot order. A new array every call. + public static Gamepad[] getGamepads() { + ArrayList out = new ArrayList(); + for (Object p : PADS) { + if (p != null) { + out.add(p); + } + } + Gamepad[] r = new Gamepad[out.size()]; + for (int i = 0; i < r.length; i++) { + r[i] = (Gamepad) out.get(i); + } + return r; + } + + /// The controller in the given slot, or null when that slot is empty. + public static Gamepad getGamepad(int index) { + if (index < 0 || index >= PADS.size()) { + return null; + } + return (Gamepad) PADS.get(index); + } + + /// True when at least one controller is connected. + public static boolean isAnyConnected() { + for (Object p : PADS) { + if (p != null) { + return true; + } + } + return false; + } + + /// Registers a listener. + public static void addListener(GamepadListener l) { + if (l != null && !LISTENERS.contains(l)) { + LISTENERS.add(l); + } + } + + /// Unregisters a listener. + public static void removeListener(GamepadListener l) { + LISTENERS.remove(l); + } + + /// Whether unconsumed buttons drive focus navigation; on by default. + public static boolean isFocusNavigation() { + return focusNavigation; + } + + /// Turns focus navigation from controller buttons on or off. A game that reads + /// the controller itself and has no focusable components may turn it off. + public static void setFocusNavigation(boolean b) { + focusNavigation = b; + } + + /// Called by a port when a controller connects; `index` is the slot it chose. + /// Returns the controller object. + public static Gamepad reportConnected(int index, String name) { + if (index < 0) { + return null; + } + while (PADS.size() <= index) { + PADS.add(null); + } + Gamepad existing = (Gamepad) PADS.get(index); + if (existing != null) { + return existing; + } + Gamepad pad = new Gamepad(index, name); + PADS.set(index, pad); + ensureStickSlots(index); + stickX[index] = -1; + stickY[index] = -1; + Object[] ls = LISTENERS.toArray(); + for (Object l : ls) { + ((GamepadListener) l).gamepadConnected(pad); + } + return pad; + } + + /// Called by a port when a controller disconnects. Releases every button it + /// still held first, so nothing is left stuck down. + public static void reportDisconnected(int index) { + Gamepad pad = getGamepad(index); + if (pad == null) { + return; + } + for (int b = 0; b < Gamepad.BUTTON_COUNT; b++) { + if (pad.isButtonDown(b)) { + reportButton(index, b, false); + } + } + for (int a = 0; a < Gamepad.AXIS_COUNT; a++) { + reportAxis(index, a, 0); + } + pad.setConnected(false); + PADS.set(index, null); + Object[] ls = LISTENERS.toArray(); + for (Object l : ls) { + ((GamepadListener) l).gamepadDisconnected(pad); + } + } + + /// Called by a port when a button changes. + public static void reportButton(int index, int button, boolean down) { + reportButton(index, button, down, true); + } + + /// Called by a port when a button changes. A port that already delivers the + /// controller to the user interface as keys (the Xbox build of the Windows + /// port does) passes `navigate` false, so the press is not delivered twice. + public static void reportButton(int index, int button, boolean down, boolean navigate) { + Gamepad pad = getGamepad(index); + if (pad == null || !pad.setButton(button, down)) { + return; + } + boolean consumed = false; + Object[] ls = LISTENERS.toArray(); + for (Object l : ls) { + if (((GamepadListener) l).gamepadButton(pad, button, down)) { + consumed = true; + } + } + if (navigate && !consumed && focusNavigation) { + navigate(button, down); + } + } + + /// Called by a port when an axis changes: sticks -1 .. 1, triggers 0 .. 1. + /// The triggers are also reported as `Gamepad#BUTTON_LEFT_TRIGGER` and + /// `Gamepad#BUTTON_RIGHT_TRIGGER` past halfway, and the left stick as the + /// D-pad for focus navigation. + public static void reportAxis(int index, int axis, float value) { + Gamepad pad = getGamepad(index); + if (pad == null) { + return; + } + pad.setAxis(axis, value); + float v = pad.getRawAxis(axis); + if (axis == Gamepad.AXIS_LEFT_TRIGGER) { + reportButton(index, Gamepad.BUTTON_LEFT_TRIGGER, v > 0.5f); + } else if (axis == Gamepad.AXIS_RIGHT_TRIGGER) { + reportButton(index, Gamepad.BUTTON_RIGHT_TRIGGER, v > 0.5f); + } else if (axis == Gamepad.AXIS_LEFT_X) { + stickX[index] = stickDirection(stickX[index], v, + Gamepad.BUTTON_DPAD_LEFT, Gamepad.BUTTON_DPAD_RIGHT); + } else if (axis == Gamepad.AXIS_LEFT_Y) { + stickY[index] = stickDirection(stickY[index], v, + Gamepad.BUTTON_DPAD_UP, Gamepad.BUTTON_DPAD_DOWN); + } + } + + /// The stick held as a D-pad direction, with hysteresis. Only drives focus + /// navigation: it never sets the pad's D-pad buttons, which stay what the + /// device reports. + private static int stickDirection(int current, float v, int negative, int positive) { + int next = current; + if (current == -1) { + if (v <= -STICK_PRESS) { + next = negative; + } else if (v >= STICK_PRESS) { + next = positive; + } + } else { + float along = current == negative ? -v : v; + if (along < STICK_RELEASE) { + next = -1; + if (v <= -STICK_PRESS) { + next = negative; + } else if (v >= STICK_PRESS) { + next = positive; + } + } + } + if (next != current && focusNavigation) { + if (current != -1) { + navigate(current, false); + } + if (next != -1) { + navigate(next, true); + } + } + return next; + } + + private static void ensureStickSlots(int index) { + if (stickX.length <= index) { + int[] nx = new int[index + 1]; + int[] ny = new int[index + 1]; + System.arraycopy(stickX, 0, nx, 0, stickX.length); + System.arraycopy(stickY, 0, ny, 0, stickY.length); + stickX = nx; + stickY = ny; + } + } + + /// The Codename One key code a button navigates with, or 0 for none. + static int navigationKeyCode(int button) { + Display d = Display.getInstance(); + switch (button) { + case Gamepad.BUTTON_DPAD_UP: + return d.getKeyCode(Display.GAME_UP); + case Gamepad.BUTTON_DPAD_DOWN: + return d.getKeyCode(Display.GAME_DOWN); + case Gamepad.BUTTON_DPAD_LEFT: + return d.getKeyCode(Display.GAME_LEFT); + case Gamepad.BUTTON_DPAD_RIGHT: + return d.getKeyCode(Display.GAME_RIGHT); + case Gamepad.BUTTON_A: + return d.getKeyCode(Display.GAME_FIRE); + default: + return 0; + } + } + + private static void navigate(int button, boolean down) { + if (button == Gamepad.BUTTON_B) { + if (!down) { + goBack(); + } + return; + } + int code = navigationKeyCode(button); + // 0 and -1 are what getKeyCode answers for a game action the port has + // no key for. + if (code == 0 || code == -1) { + return; + } + Display d = Display.getInstance(); + if (down) { + d.keyPressed(code); + } else { + d.keyReleased(code); + } + } + + private static void goBack() { + Form f = Display.getInstance().getCurrent(); + if (f == null) { + return; + } + Command back = f.getBackCommand(); + if (back != null) { + f.dispatchCommand(back, new ActionEvent(back, ActionEvent.Type.Command)); + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index be7e88af2b8..8f181a9cc2b 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -479,6 +479,11 @@ public void playDialogSound(final int type) { public void vibrate(int duration) { } + /// Rumbles game controller `index`; motors are 0 to 1. A port with + /// controller force feedback overrides this, the rest ignore it. + public void rumbleGamepad(int index, float strong, float weak, int durationMillis) { + } + /// Flash the backlight of the device for the given length of time /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index f358cd8c070..9fd8b6367fb 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -863,6 +863,24 @@ public void vibrate(int duration) { impl.vibrate(duration); } + /// Rumbles a game controller, when the port and the controller can. + /// + /// Applications call `com.codename1.gaming.Gamepad#rumble(float, float, int)` + /// rather than this. + /// + /// #### Parameters + /// + /// - `index`: the controller, as `com.codename1.gaming.Gamepad#getIndex()` + /// + /// - `strong`: the low frequency motor, 0 to 1 + /// + /// - `weak`: the high frequency motor, 0 to 1 + /// + /// - `durationMillis`: how long to rumble + public void rumbleGamepad(int index, float strong, float weak, int durationMillis) { + impl.rumbleGamepad(index, strong, weak, durationMillis); + } + /// Flash the backlight of the device for the given length of time /// /// #### Parameters diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 46e0c956ba9..fb3a03faeda 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -101,9 +101,19 @@ typedef enum { * command runs on the EDT rather than on the GTK thread. Same number as the Windows * port's, so the two desktop wire protocols do not drift apart. */ CN1_EVENT_MENU_COMMAND = 23, - CN1_EVENT_THEME_SETTINGS_CHANGED = 24 + CN1_EVENT_THEME_SETTINGS_CHANGED = 24, + /* Game controllers (cn1_linux_gamepad.c). x is the controller slot. A button + * carries the Gamepad.BUTTON_* in y and 1/0 in keyCode; an axis carries the + * Gamepad.AXIS_* in y and the value times CN1_GAMEPAD_FIXED in keyCode. */ + CN1_EVENT_GAMEPAD_CONNECTED = 25, + CN1_EVENT_GAMEPAD_DISCONNECTED = 26, + CN1_EVENT_GAMEPAD_BUTTON = 27, + CN1_EVENT_GAMEPAD_AXIS = 28 } CN1EventType; +/* Fixed-point scale of a CN1_EVENT_GAMEPAD_AXIS value. */ +#define CN1_GAMEPAD_FIXED 10000 + /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ #define CN1_GESTURE_FIXED 10000 diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 29c74b98b21..6c17e411ac6 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -92,12 +92,6 @@ X(EVP_PKEY_CTX_free) \ X(EVP_PKEY_CTX_new) \ X(EVP_PKEY_CTX_new_id) \ - X(EVP_PKEY_CTX_set_ec_paramgen_curve_nid) \ - X(EVP_PKEY_CTX_set_rsa_keygen_bits) \ - X(EVP_PKEY_CTX_set_rsa_mgf1_md) \ - X(EVP_PKEY_CTX_set_rsa_oaep_md) \ - X(EVP_PKEY_CTX_set_rsa_padding) \ - X(EVP_PKEY_get_base_id) \ X(EVP_PKEY_decrypt) \ X(EVP_PKEY_decrypt_init) \ X(EVP_PKEY_derive) \ @@ -106,7 +100,6 @@ X(EVP_PKEY_encrypt) \ X(EVP_PKEY_encrypt_init) \ X(EVP_PKEY_free) \ - X(EVP_PKEY_get_id) \ X(EVP_PKEY_keygen) \ X(EVP_PKEY_keygen_init) \ X(EVP_aes_128_cbc) \ @@ -131,26 +124,121 @@ X(i2d_PKCS8_PRIV_KEY_INFO) \ X(i2d_PUBKEY) +/* OpenSSL 3 functions that 1.1 does not export: the two getters are renamed + * there, and the EVP_PKEY_CTX_set_* calls are macros over EVP_PKEY_CTX_ctrl. */ +#define CN1_CRYPTO_V3_FUNCTIONS(X) \ + X(EVP_PKEY_CTX_set_ec_paramgen_curve_nid) \ + X(EVP_PKEY_CTX_set_rsa_keygen_bits) \ + X(EVP_PKEY_CTX_set_rsa_mgf1_md) \ + X(EVP_PKEY_CTX_set_rsa_oaep_md) \ + X(EVP_PKEY_CTX_set_rsa_padding) \ + X(EVP_PKEY_get_base_id) \ + X(EVP_PKEY_get_id) + #define CN1_CRYPTO_POINTER(name) static __typeof__(name)* p_##name; CN1_CRYPTO_FUNCTIONS(CN1_CRYPTO_POINTER) +CN1_CRYPTO_V3_FUNCTIONS(CN1_CRYPTO_POINTER) #undef CN1_CRYPTO_POINTER +/* + * OpenSSL 1.1 fallback. The glibc 2.28 target runs on RHEL 8 and Debian 11, and + * the Steam Linux Runtime is Debian 11 based: all ship libcrypto.so.1.1 and no + * libcrypto.so.3, so crypto used to report unavailable there. The 1.1 ABI is + * the same for every other function used here; these stand in for the seven + * that differ, with the control codes and operation bits of the 1.1.1 headers + * spelled out (the build compiles against the 3 headers). + */ +typedef int (*Cn1PkeyCtxCtrl)(EVP_PKEY_CTX*, int, int, int, int, void*); +typedef int (*Cn1PkeyId)(const EVP_PKEY*); +static Cn1PkeyCtxCtrl p11_EVP_PKEY_CTX_ctrl; +static Cn1PkeyId p11_EVP_PKEY_id; +static Cn1PkeyId p11_EVP_PKEY_base_id; + +#define CN1_11_PKEY_RSA 6 +#define CN1_11_PKEY_EC 408 +#define CN1_11_OP_PARAMGEN (1 << 1) +#define CN1_11_OP_KEYGEN (1 << 2) +#define CN1_11_OP_TYPE_SIG ((1 << 3) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7)) +#define CN1_11_OP_TYPE_CRYPT ((1 << 8) | (1 << 9)) +#define CN1_11_ALG_CTRL 0x1000 +#define CN1_11_CTRL_RSA_PADDING (CN1_11_ALG_CTRL + 1) +#define CN1_11_CTRL_RSA_KEYGEN_BITS (CN1_11_ALG_CTRL + 3) +#define CN1_11_CTRL_RSA_MGF1_MD (CN1_11_ALG_CTRL + 5) +#define CN1_11_CTRL_RSA_OAEP_MD (CN1_11_ALG_CTRL + 9) +#define CN1_11_CTRL_EC_PARAMGEN_CURVE_NID (CN1_11_ALG_CTRL + 1) + +static int cn1v11_set_ec_paramgen_curve_nid(EVP_PKEY_CTX* ctx, int nid) { + return p11_EVP_PKEY_CTX_ctrl(ctx, CN1_11_PKEY_EC, CN1_11_OP_PARAMGEN | CN1_11_OP_KEYGEN, + CN1_11_CTRL_EC_PARAMGEN_CURVE_NID, nid, NULL); +} + +static int cn1v11_set_rsa_keygen_bits(EVP_PKEY_CTX* ctx, int bits) { + return p11_EVP_PKEY_CTX_ctrl(ctx, CN1_11_PKEY_RSA, CN1_11_OP_KEYGEN, + CN1_11_CTRL_RSA_KEYGEN_BITS, bits, NULL); +} + +static int cn1v11_set_rsa_mgf1_md(EVP_PKEY_CTX* ctx, const EVP_MD* md) { + return p11_EVP_PKEY_CTX_ctrl(ctx, -1, CN1_11_OP_TYPE_SIG | CN1_11_OP_TYPE_CRYPT, + CN1_11_CTRL_RSA_MGF1_MD, 0, (void*) md); +} + +static int cn1v11_set_rsa_oaep_md(EVP_PKEY_CTX* ctx, const EVP_MD* md) { + return p11_EVP_PKEY_CTX_ctrl(ctx, CN1_11_PKEY_RSA, CN1_11_OP_TYPE_CRYPT, + CN1_11_CTRL_RSA_OAEP_MD, 0, (void*) md); +} + +static int cn1v11_set_rsa_padding(EVP_PKEY_CTX* ctx, int pad) { + return p11_EVP_PKEY_CTX_ctrl(ctx, -1, -1, CN1_11_CTRL_RSA_PADDING, pad, NULL); +} + +static int cn1v11_get_base_id(const EVP_PKEY* k) { + return p11_EVP_PKEY_base_id(k); +} + +static int cn1v11_get_id(const EVP_PKEY* k) { + return p11_EVP_PKEY_id(k); +} + static pthread_once_t cn1CryptoOnce = PTHREAD_ONCE_INIT; static int cn1CryptoAvailable; static void cn1LoadCrypto(void) { + int v3 = 1; void* library = dlopen("libcrypto.so.3", RTLD_NOW | RTLD_LOCAL); if (library == NULL) { - fprintf(stderr, "Codename One: unable to load libcrypto.so.3: %s\n", dlerror()); + v3 = 0; + library = dlopen("libcrypto.so.1.1", RTLD_NOW | RTLD_LOCAL); + } + if (library == NULL) { + fprintf(stderr, "Codename One: unable to load libcrypto.so.3 or libcrypto.so.1.1: %s\n", dlerror()); return; } #define CN1_CRYPTO_RESOLVE(name) \ if ((*(void**) (&p_##name) = dlsym(library, #name)) == NULL) { \ - fprintf(stderr, "Codename One: libcrypto.so.3 is missing %s\n", #name); \ + fprintf(stderr, "Codename One: libcrypto is missing %s\n", #name); \ dlclose(library); \ return; \ } CN1_CRYPTO_FUNCTIONS(CN1_CRYPTO_RESOLVE) + if (v3) { + CN1_CRYPTO_V3_FUNCTIONS(CN1_CRYPTO_RESOLVE) + } else { + *(void**) (&p11_EVP_PKEY_CTX_ctrl) = dlsym(library, "EVP_PKEY_CTX_ctrl"); + *(void**) (&p11_EVP_PKEY_id) = dlsym(library, "EVP_PKEY_id"); + *(void**) (&p11_EVP_PKEY_base_id) = dlsym(library, "EVP_PKEY_base_id"); + if (p11_EVP_PKEY_CTX_ctrl == NULL || p11_EVP_PKEY_id == NULL || p11_EVP_PKEY_base_id == NULL) { + fprintf(stderr, "Codename One: libcrypto.so.1.1 is missing EVP_PKEY_CTX_ctrl or EVP_PKEY_id\n"); + dlclose(library); + return; + } + p_EVP_PKEY_CTX_set_ec_paramgen_curve_nid = cn1v11_set_ec_paramgen_curve_nid; + p_EVP_PKEY_CTX_set_rsa_keygen_bits = cn1v11_set_rsa_keygen_bits; + p_EVP_PKEY_CTX_set_rsa_mgf1_md = cn1v11_set_rsa_mgf1_md; + p_EVP_PKEY_CTX_set_rsa_oaep_md = cn1v11_set_rsa_oaep_md; + p_EVP_PKEY_CTX_set_rsa_padding = cn1v11_set_rsa_padding; + p_EVP_PKEY_get_base_id = cn1v11_get_base_id; + p_EVP_PKEY_get_id = cn1v11_get_id; + } #undef CN1_CRYPTO_RESOLVE cn1CryptoAvailable = 1; } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_events.c b/Ports/LinuxPort/nativeSources/cn1_linux_events.c index ede891c5fd2..1a5f51a3528 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_events.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_events.c @@ -78,6 +78,14 @@ static int cn1LinuxIsProtectedEvent(int type, int x, int y) { || type == CN1_EVENT_POINTER_RELEASED || type == CN1_EVENT_WINDOW_FOCUS || type == CN1_EVENT_SIZE_CHANGED + /* A lost button release would leave a controller button held down for + * good, and a lost connect or disconnect would leave the slot table + * wrong. Presses are protected with releases because this test does not + * see the keyCode that tells them apart; buttons are rare next to the + * axis stream, which the next value repairs and so may go. */ + || type == CN1_EVENT_GAMEPAD_CONNECTED + || type == CN1_EVENT_GAMEPAD_DISCONNECTED + || type == CN1_EVENT_GAMEPAD_BUTTON || (type == CN1_EVENT_POINTER_HOVER && x == -1 && y == -1); } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c b/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c new file mode 100644 index 00000000000..afd5da2bc55 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * Game controllers over evdev (com.codename1.gaming.Gamepads). + * + * One reader thread polls every /dev/input/event* node that looks like a + * gamepad, normalizes buttons and axes to the standard (Xbox) layout and pushes + * them onto the shared input ring as CN1_EVENT_GAMEPAD_* events; the EDT drains + * them with every other input and LinuxImplementation reports them to Gamepads. + * + * evdev rather than SDL or libudev, on purpose: + * - it needs no library, so it runs inside the Steam Linux Runtime container + * and in the embedded (no GTK) build alike; + * - under Steam, Steam Input presents every controller -- the Deck's own + * controls, a Steam Controller, a DualSense -- as one virtual Xbox pad, so + * the xpad layout is the only one that has to be right; + * - hotplug is inotify on /dev/input. IN_ATTRIB matters as much as IN_CREATE: + * udev creates the node first and grants the user access a moment later. + * + * Steam also leaves the physical controllers' own nodes visible and names them + * in SDL_GAMECONTROLLER_IGNORE_DEVICES ("0xVID/0xPID,..."); those are skipped, + * or every Steam Input pad would show up twice. + */ + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "cn1_linux.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern JAVA_OBJECT newStringFromCString(CODENAME_ONE_THREAD_STATE, const char* str); + +#define CN1_PAD_MAX 8 +#define CN1_PAD_NAME 128 +#define CN1_INPUT_DIR "/dev/input" + +/* Mirrors com.codename1.gaming.Gamepad.BUTTON_* and AXIS_*. */ +enum { + PAD_A = 0, PAD_B, PAD_X, PAD_Y, PAD_LB, PAD_RB, PAD_BACK, PAD_START, PAD_GUIDE, + PAD_LS, PAD_RS, PAD_UP, PAD_DOWN, PAD_LEFT, PAD_RIGHT +}; +enum { AX_LX = 0, AX_LY, AX_RX, AX_RY, AX_LT, AX_RT }; + +#define BITS_PER_LONG (sizeof(long) * 8) +#define NLONGS(x) (((x) + BITS_PER_LONG - 1) / BITS_PER_LONG) +#define TEST_BIT(bit, arr) (((arr)[(bit) / BITS_PER_LONG] >> ((bit) % BITS_PER_LONG)) & 1UL) + +typedef struct { + int fd; /* -1 when the slot is free */ + char path[64]; + char name[CN1_PAD_NAME]; + struct input_absinfo abs[ABS_CNT]; + int hasAbs[ABS_CNT]; + int lastAxis[6]; /* last pushed value, fixed point */ + int hatX, hatY; + int rumbleEffect; /* uploaded FF effect id, or -1 */ +} Cn1Pad; + +static Cn1Pad cn1Pads[CN1_PAD_MAX]; +static pthread_mutex_t cn1PadLock = PTHREAD_MUTEX_INITIALIZER; +static int cn1PadStarted = 0; + +static void cn1PadPush(int type, int index, int a, int b) { + cn1LinuxPushEvent(type, index, a, b); +} + +/* "0x28de/0x1205,0x045e/0x028e" -> is vid/pid listed? */ +static int cn1PadIgnored(unsigned vid, unsigned pid) { + const char* list = getenv("SDL_GAMECONTROLLER_IGNORE_DEVICES"); + const char* p = list; + if (list == NULL) { + return 0; + } + while (*p) { + unsigned v = 0, d = 0; + if (sscanf(p, "%x/%x", &v, &d) == 2 && v == vid && d == pid) { + return 1; + } + while (*p && *p != ',') { + p++; + } + if (*p == ',') { + p++; + } + } + return 0; +} + +static int cn1PadIsGamepad(int fd) { + unsigned long evbits[NLONGS(EV_CNT)]; + unsigned long keybits[NLONGS(KEY_CNT)]; + unsigned long absbits[NLONGS(ABS_CNT)]; + memset(evbits, 0, sizeof(evbits)); + memset(keybits, 0, sizeof(keybits)); + memset(absbits, 0, sizeof(absbits)); + if (ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits) < 0 + || !TEST_BIT(EV_KEY, evbits)) { + return 0; + } + ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits); + if (TEST_BIT(BTN_GAMEPAD, keybits)) { + return 1; + } + /* Older joystick-class pads report BTN_JOYSTICK plus sticks. */ + if (TEST_BIT(EV_ABS, evbits) && TEST_BIT(BTN_JOYSTICK, keybits)) { + ioctl(fd, EVIOCGBIT(EV_ABS, sizeof(absbits)), absbits); + return TEST_BIT(ABS_X, absbits) && TEST_BIT(ABS_Y, absbits); + } + return 0; +} + +static int cn1PadFindPathLocked(const char* path) { + int i; + for (i = 0; i < CN1_PAD_MAX; i++) { + if (cn1Pads[i].fd >= 0 && strcmp(cn1Pads[i].path, path) == 0) { + return i; + } + } + return -1; +} + +static void cn1PadOpen(const char* path) { + int fd, slot = -1, i; + struct input_id id; + pthread_mutex_lock(&cn1PadLock); + if (cn1PadFindPathLocked(path) >= 0) { + pthread_mutex_unlock(&cn1PadLock); + return; + } + pthread_mutex_unlock(&cn1PadLock); + + /* Read-write for force feedback; read-only still gives input. */ + fd = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + } + if (fd < 0) { + return; + } + if (!cn1PadIsGamepad(fd)) { + close(fd); + return; + } + memset(&id, 0, sizeof(id)); + if (ioctl(fd, EVIOCGID, &id) == 0 && cn1PadIgnored(id.vendor, id.product)) { + close(fd); + return; + } + + pthread_mutex_lock(&cn1PadLock); + for (i = 0; i < CN1_PAD_MAX; i++) { + if (cn1Pads[i].fd < 0) { + slot = i; + break; + } + } + if (slot < 0) { + pthread_mutex_unlock(&cn1PadLock); + close(fd); + return; + } + { + Cn1Pad* pad = &cn1Pads[slot]; + int a; + unsigned long absbits[NLONGS(ABS_CNT)]; + memset(pad, 0, sizeof(*pad)); + pad->fd = fd; + pad->rumbleEffect = -1; + /* -1 is "no direction"; 0 would be PAD_A and fake an A release. */ + pad->hatX = -1; + pad->hatY = -1; + snprintf(pad->path, sizeof(pad->path), "%s", path); + if (ioctl(fd, EVIOCGNAME(sizeof(pad->name) - 1), pad->name) < 0) { + pad->name[0] = 0; + } + memset(absbits, 0, sizeof(absbits)); + ioctl(fd, EVIOCGBIT(EV_ABS, sizeof(absbits)), absbits); + for (a = 0; a < ABS_CNT; a++) { + if (TEST_BIT(a, absbits) && ioctl(fd, EVIOCGABS(a), &pad->abs[a]) == 0 + && pad->abs[a].maximum > pad->abs[a].minimum) { + pad->hasAbs[a] = 1; + } + } + for (a = 0; a < 6; a++) { + pad->lastAxis[a] = 0x7fffffff; + } + } + pthread_mutex_unlock(&cn1PadLock); + cn1PadPush(CN1_EVENT_GAMEPAD_CONNECTED, slot, 0, 0); +} + +static void cn1PadClose(int slot) { + pthread_mutex_lock(&cn1PadLock); + if (cn1Pads[slot].fd >= 0) { + close(cn1Pads[slot].fd); + } + cn1Pads[slot].fd = -1; + pthread_mutex_unlock(&cn1PadLock); + cn1PadPush(CN1_EVENT_GAMEPAD_DISCONNECTED, slot, 0, 0); +} + +static int cn1PadButtonOf(int code) { + switch (code) { + case BTN_SOUTH: return PAD_A; + case BTN_EAST: return PAD_B; + /* xpad, and Steam Input's virtual pad after it, report X as BTN_NORTH + * (BTN_X) and Y as BTN_WEST (BTN_Y) -- the codes' names, not their + * compass points. SDL's mapping for these devices agrees. */ + case BTN_NORTH: return PAD_X; + case BTN_WEST: return PAD_Y; + case BTN_TL: return PAD_LB; + case BTN_TR: return PAD_RB; + case BTN_SELECT: return PAD_BACK; + case BTN_START: return PAD_START; + case BTN_MODE: return PAD_GUIDE; + case BTN_THUMBL: return PAD_LS; + case BTN_THUMBR: return PAD_RS; + case BTN_DPAD_UP: return PAD_UP; + case BTN_DPAD_DOWN: return PAD_DOWN; + case BTN_DPAD_LEFT: return PAD_LEFT; + case BTN_DPAD_RIGHT: return PAD_RIGHT; + default: return -1; + } +} + +static void cn1PadAxis(int slot, Cn1Pad* pad, int axis, double v) { + int fixed = (int) (v * CN1_GAMEPAD_FIXED); + int last = pad->lastAxis[axis]; + /* Sticks report hundreds of values a second; anything finer than 1/256 of + * the travel is noise and only fills the ring. */ + /* The rest and end positions always go through, or a stick let go just + * under the threshold would read as slightly pushed forever. */ + int edge; + if (fixed == last) { + return; + } + edge = fixed == 0 || abs(fixed) >= CN1_GAMEPAD_FIXED; + if (last != 0x7fffffff && !edge && abs(fixed - last) < CN1_GAMEPAD_FIXED / 256) { + return; + } + pad->lastAxis[axis] = fixed; + cn1PadPush(CN1_EVENT_GAMEPAD_AXIS, slot, axis, fixed); +} + +static double cn1PadNorm(const struct input_absinfo* ai, int value, int centered) { + double t; + /* The driver's own dead zone: a worn stick resting off-center reads 0. */ + if (centered && ai->flat > 0) { + int center = ai->minimum + (ai->maximum - ai->minimum) / 2; + if (abs(value - center) <= ai->flat) { + return 0; + } + } + t = (double) (value - ai->minimum) / (double) (ai->maximum - ai->minimum); + if (t < 0) { + t = 0; + } else if (t > 1) { + t = 1; + } + return centered ? t * 2.0 - 1.0 : t; +} + +static void cn1PadHat(int slot, int* current, int value, int neg, int pos) { + int next = value < 0 ? neg : (value > 0 ? pos : -1); + if (next == *current) { + return; + } + if (*current >= 0) { + cn1PadPush(CN1_EVENT_GAMEPAD_BUTTON, slot, *current, 0); + } + if (next >= 0) { + cn1PadPush(CN1_EVENT_GAMEPAD_BUTTON, slot, next, 1); + } + *current = next; +} + +static void cn1PadHandle(int slot, Cn1Pad* pad, const struct input_event* ev) { + if (ev->type == EV_KEY) { + int b = cn1PadButtonOf(ev->code); + if (ev->value == 2) { + return; /* autorepeat */ + } + if (b >= 0) { + cn1PadPush(CN1_EVENT_GAMEPAD_BUTTON, slot, b, ev->value ? 1 : 0); + } else if (ev->code == BTN_TL2 && !pad->hasAbs[ABS_Z] && !pad->hasAbs[ABS_BRAKE]) { + cn1PadAxis(slot, pad, AX_LT, ev->value ? 1.0 : 0.0); + } else if (ev->code == BTN_TR2 && !pad->hasAbs[ABS_RZ] && !pad->hasAbs[ABS_GAS]) { + cn1PadAxis(slot, pad, AX_RT, ev->value ? 1.0 : 0.0); + } + return; + } + if (ev->type != EV_ABS || ev->code >= ABS_CNT || !pad->hasAbs[ev->code]) { + return; + } + switch (ev->code) { + case ABS_X: cn1PadAxis(slot, pad, AX_LX, cn1PadNorm(&pad->abs[ABS_X], ev->value, 1)); break; + case ABS_Y: cn1PadAxis(slot, pad, AX_LY, cn1PadNorm(&pad->abs[ABS_Y], ev->value, 1)); break; + case ABS_RX: cn1PadAxis(slot, pad, AX_RX, cn1PadNorm(&pad->abs[ABS_RX], ev->value, 1)); break; + case ABS_RY: cn1PadAxis(slot, pad, AX_RY, cn1PadNorm(&pad->abs[ABS_RY], ev->value, 1)); break; + case ABS_Z: + case ABS_BRAKE: + cn1PadAxis(slot, pad, AX_LT, cn1PadNorm(&pad->abs[ev->code], ev->value, 0)); + break; + case ABS_RZ: + case ABS_GAS: + cn1PadAxis(slot, pad, AX_RT, cn1PadNorm(&pad->abs[ev->code], ev->value, 0)); + break; + case ABS_HAT0X: cn1PadHat(slot, &pad->hatX, ev->value, PAD_LEFT, PAD_RIGHT); break; + case ABS_HAT0Y: cn1PadHat(slot, &pad->hatY, ev->value, PAD_UP, PAD_DOWN); break; + default: break; + } +} + +static void cn1PadScan(void) { + DIR* d = opendir(CN1_INPUT_DIR); + struct dirent* e; + char path[64]; + if (d == NULL) { + return; + } + while ((e = readdir(d)) != NULL) { + if (strncmp(e->d_name, "event", 5) == 0) { + snprintf(path, sizeof(path), CN1_INPUT_DIR "/%s", e->d_name); + cn1PadOpen(path); + } + } + closedir(d); +} + +static void* cn1PadThread(void* arg) { + int ino = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + (void) arg; + if (ino >= 0) { + inotify_add_watch(ino, CN1_INPUT_DIR, IN_CREATE | IN_ATTRIB); + } + cn1PadScan(); + for (;;) { + struct pollfd fds[CN1_PAD_MAX + 1]; + int slots[CN1_PAD_MAX + 1]; + int n = 0, i; + if (ino >= 0) { + fds[n].fd = ino; + fds[n].events = POLLIN; + slots[n] = -1; + n++; + } + pthread_mutex_lock(&cn1PadLock); + for (i = 0; i < CN1_PAD_MAX; i++) { + if (cn1Pads[i].fd >= 0) { + fds[n].fd = cn1Pads[i].fd; + fds[n].events = POLLIN; + slots[n] = i; + n++; + } + } + pthread_mutex_unlock(&cn1PadLock); + if (poll(fds, (nfds_t) n, ino >= 0 ? -1 : 2000) < 0) { + if (errno == EINTR) { + continue; + } + usleep(100000); + continue; + } + if (ino < 0) { + /* No inotify (an unusual sandbox): rescan now and then instead. */ + cn1PadScan(); + } + for (i = 0; i < n; i++) { + if (fds[i].revents == 0) { + continue; + } + if (slots[i] < 0) { + char buf[4096]; + ssize_t len; + int changed = 0; + while ((len = read(ino, buf, sizeof(buf))) > 0) { + changed = 1; + } + if (changed) { + cn1PadScan(); + } + continue; + } + if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { + cn1PadClose(slots[i]); + continue; + } + { + struct input_event evs[64]; + ssize_t len; + int slot = slots[i]; + Cn1Pad* pad = &cn1Pads[slot]; + while ((len = read(fds[i].fd, evs, sizeof(evs))) > 0) { + int k, count = (int) (len / (ssize_t) sizeof(struct input_event)); + for (k = 0; k < count; k++) { + cn1PadHandle(slot, pad, &evs[k]); + } + } + if (len < 0 && errno == ENODEV) { + cn1PadClose(slot); + } + } + } + } + return NULL; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_gamepadStart__(CODENAME_ONE_THREAD_STATE) { + pthread_t t; + pthread_attr_t attr; + int i; + pthread_mutex_lock(&cn1PadLock); + if (cn1PadStarted) { + pthread_mutex_unlock(&cn1PadLock); + return; + } + cn1PadStarted = 1; + for (i = 0; i < CN1_PAD_MAX; i++) { + cn1Pads[i].fd = -1; + } + pthread_mutex_unlock(&cn1PadLock); + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + pthread_create(&t, &attr, cn1PadThread, NULL); + pthread_attr_destroy(&attr); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_gamepadName___int_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_INT index) { + char name[CN1_PAD_NAME]; + name[0] = 0; + if (index >= 0 && index < CN1_PAD_MAX) { + pthread_mutex_lock(&cn1PadLock); + if (cn1Pads[index].fd >= 0) { + snprintf(name, sizeof(name), "%s", cn1Pads[index].name); + } + pthread_mutex_unlock(&cn1PadLock); + } + return newStringFromCString(threadStateData, name); +} + +/* strong/weak are 0..65535, the units FF_RUMBLE takes. */ +JAVA_VOID com_codename1_impl_linux_LinuxNative_gamepadRumble___int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_INT index, JAVA_INT strong, JAVA_INT weak, JAVA_INT millis) { + struct ff_effect effect; + struct input_event play; + Cn1Pad* pad; + if (index < 0 || index >= CN1_PAD_MAX) { + return; + } + pthread_mutex_lock(&cn1PadLock); + pad = &cn1Pads[index]; + if (pad->fd < 0) { + pthread_mutex_unlock(&cn1PadLock); + return; + } + memset(&effect, 0, sizeof(effect)); + effect.type = FF_RUMBLE; + effect.id = (short) pad->rumbleEffect; + effect.u.rumble.strong_magnitude = (unsigned short) (strong < 0 ? 0 : (strong > 0xffff ? 0xffff : strong)); + effect.u.rumble.weak_magnitude = (unsigned short) (weak < 0 ? 0 : (weak > 0xffff ? 0xffff : weak)); + effect.replay.length = (unsigned short) (millis < 0 ? 0 : (millis > 0xffff ? 0xffff : millis)); + /* Re-uploading with the same id updates the effect in place. */ + if (ioctl(pad->fd, EVIOCSFF, &effect) == 0) { + pad->rumbleEffect = effect.id; + memset(&play, 0, sizeof(play)); + play.type = EV_FF; + play.code = (unsigned short) effect.id; + play.value = 1; + if (write(pad->fd, &play, sizeof(play)) < 0) { + /* The device went away or is read-only; rumble is best effort. */ + } + } + pthread_mutex_unlock(&cn1PadLock); +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_steam.c b/Ports/LinuxPort/nativeSources/cn1_linux_steam.c new file mode 100644 index 00000000000..cec7cb85852 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_steam.c @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * Steamworks (com.codename1.gaming.steam.Steam), loaded at run time. + * + * libsteam_api.so is dlopen()ed beside the executable and called through the + * Steamworks *flat* API, which is plain C ABI (S_API is extern "C" and every + * interface is an opaque pointer). That choice is what keeps this port pure C + * and keeps every Steamworks file out of this repository: + * - no SDK header is compiled here; the dozen entry points used are declared + * below from the published flat API, the way every binding outside C++ does; + * - the same binary runs with and without Steam: no library, or SteamAPI init + * failing (not launched by Steam, no steam_appid.txt), is "unavailable" and + * every call is a harmless no-op; + * - the Steam build target only has to put the redistributable + * libsteam_api.so in the depot (the cloud builder injects it). + * + * Interface accessors are versioned (SteamAPI_SteamUserStats_v013 ...) and the + * version moves with SDK releases, so the newest one the loaded library exports + * is picked: the unversioned flat methods are always compiled against that + * library's newest interface. + * + * Callbacks use manual dispatch, pumped by LinuxSteam's thread through + * steamPump(); the only ones decoded are the overlay opening and closing and + * the floating keyboard being dismissed. Call results are freed unread. + */ + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "cn1_linux.h" +#include +#include +#include +#include +#include +#include +#include +#include + +extern JAVA_OBJECT newStringFromCString(CODENAME_ONE_THREAD_STATE, const char* str); +extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); +extern JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n); + +typedef unsigned char sbool; /* C++ bool across the flat ABI */ +typedef void* SIface; + +typedef struct { + int32_t user; + int32_t callback; + uint8_t* param; + int32_t paramSize; +} SteamCallbackMsg; + +/* Callback ids: k_iSteamFriendsCallbacks + 31, k_iSteamUtilsCallbacks + 38. */ +#define CB_GAME_OVERLAY_ACTIVATED 331 +#define CB_FLOATING_KEYBOARD_DISMISSED 738 + +/* Events steamPump() answers; mirrored in LinuxSteam. */ +#define EV_OVERLAY_SHOWN 1 +#define EV_OVERLAY_HIDDEN 2 +#define EV_KEYBOARD_DISMISSED 3 + +static void* lib; +static int state; /* 0 untried, 1 ready, -1 unavailable */ +static int32_t pipe_; +static SIface stats, friends, user, utils, apps, storage; +static pthread_mutex_t steamLock = PTHREAD_MUTEX_INITIALIZER; + +static int (*pInitFlat)(char* errMsg); +static sbool (*pInit)(void); +static void (*pShutdown)(void); +static sbool (*pRestartAppIfNecessary)(uint32_t); +static void (*pManualInit)(void); +static int32_t (*pGetPipe)(void); +static void (*pRunFrame)(int32_t); +static sbool (*pNextCallback)(int32_t, SteamCallbackMsg*); +static void (*pFreeLastCallback)(int32_t); + +static sbool (*pSetAchievement)(SIface, const char*); +static sbool (*pClearAchievement)(SIface, const char*); +static sbool (*pGetAchievement)(SIface, const char*, sbool*); +static sbool (*pStoreStats)(SIface); +static sbool (*pRequestCurrentStats)(SIface); +static sbool (*pGetStatInt)(SIface, const char*, int32_t*); +static sbool (*pSetStatInt)(SIface, const char*, int32_t); +static sbool (*pGetStatFloat)(SIface, const char*, float*); +static sbool (*pSetStatFloat)(SIface, const char*, float); +static const char* (*pPersonaName)(SIface); +static void (*pActivateOverlay)(SIface, const char*); +static void (*pActivateOverlayWeb)(SIface, const char*, int); +static sbool (*pSetRichPresence)(SIface, const char*, const char*); +static void (*pClearRichPresence)(SIface); +static uint64_t (*pSteamId)(SIface); +static uint32_t (*pAppId)(SIface); +static sbool (*pOnDeck)(SIface); +static sbool (*pBigPicture)(SIface); +static sbool (*pFloatingKeyboard)(SIface, int, int, int, int, int); +static sbool (*pDlcInstalled)(SIface, uint32_t); +static const char* (*pLanguage)(SIface); +static sbool (*pFileWrite)(SIface, const char*, const void*, int32_t); +static int32_t (*pFileRead)(SIface, const char*, void*, int32_t); +static int32_t (*pFileSize)(SIface, const char*); +static sbool (*pFileExists)(SIface, const char*); +static sbool (*pFileDelete)(SIface, const char*); +static sbool (*pCloudAccount)(SIface); +static sbool (*pCloudApp)(SIface); + +#define SYM(var, name) (*(void**) (&(var)) = dlsym(lib, name)) + +/* The newest exported "SteamAPI__vNNN" accessor, called. */ +static SIface steamAccessor(const char* base) { + char name[96]; + int v; + for (v = 40; v > 0; v--) { + SIface (*fn)(void); + snprintf(name, sizeof(name), "SteamAPI_%s_v%03d", base, v); + *(void**) (&fn) = dlsym(lib, name); + if (fn != NULL) { + return fn(); + } + } + return NULL; +} + +static void* steamOpen(void) { + char exe[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 32); + void* h; + if (n > 0) { + char* slash; + exe[n] = 0; + slash = strrchr(exe, '/'); + if (slash != NULL) { + strcpy(slash + 1, "libsteam_api.so"); + h = dlopen(exe, RTLD_NOW | RTLD_LOCAL); + if (h != NULL) { + return h; + } + } + } + return dlopen("libsteam_api.so", RTLD_NOW | RTLD_LOCAL); +} + +static int steamLoadLocked(void) { + char err[1024]; + int ok; + if (state != 0) { + return state == 1; + } + state = -1; + if (lib == NULL) { + lib = steamOpen(); + } + if (lib == NULL) { + return 0; + } + SYM(pInitFlat, "SteamAPI_InitFlat"); + SYM(pInit, "SteamAPI_Init"); + SYM(pShutdown, "SteamAPI_Shutdown"); + SYM(pRestartAppIfNecessary, "SteamAPI_RestartAppIfNecessary"); + SYM(pManualInit, "SteamAPI_ManualDispatch_Init"); + SYM(pGetPipe, "SteamAPI_GetHSteamPipe"); + SYM(pRunFrame, "SteamAPI_ManualDispatch_RunFrame"); + SYM(pNextCallback, "SteamAPI_ManualDispatch_GetNextCallback"); + SYM(pFreeLastCallback, "SteamAPI_ManualDispatch_FreeLastCallback"); + if (pInitFlat == NULL && pInit == NULL) { + return 0; + } + err[0] = 0; + /* SteamAPI_InitFlat (SDK 1.58 on) answers k_ESteamAPIInitResult_OK, 0; + * SteamAPI_Init (older libraries) answers true. */ + ok = pInitFlat != NULL ? pInitFlat(err) == 0 : pInit() != 0; + if (!ok) { + if (err[0]) { + fprintf(stderr, "Steamworks unavailable: %s\n", err); + } + return 0; + } + if (pManualInit != NULL) { + pManualInit(); + } + pipe_ = pGetPipe != NULL ? pGetPipe() : 0; + + stats = steamAccessor("SteamUserStats"); + friends = steamAccessor("SteamFriends"); + user = steamAccessor("SteamUser"); + utils = steamAccessor("SteamUtils"); + apps = steamAccessor("SteamApps"); + storage = steamAccessor("SteamRemoteStorage"); + + SYM(pSetAchievement, "SteamAPI_ISteamUserStats_SetAchievement"); + SYM(pClearAchievement, "SteamAPI_ISteamUserStats_ClearAchievement"); + SYM(pGetAchievement, "SteamAPI_ISteamUserStats_GetAchievement"); + SYM(pStoreStats, "SteamAPI_ISteamUserStats_StoreStats"); + /* Gone from SDK 1.61, where stats load by themselves. */ + SYM(pRequestCurrentStats, "SteamAPI_ISteamUserStats_RequestCurrentStats"); + SYM(pGetStatInt, "SteamAPI_ISteamUserStats_GetStatInt32"); + SYM(pSetStatInt, "SteamAPI_ISteamUserStats_SetStatInt32"); + SYM(pGetStatFloat, "SteamAPI_ISteamUserStats_GetStatFloat"); + SYM(pSetStatFloat, "SteamAPI_ISteamUserStats_SetStatFloat"); + SYM(pPersonaName, "SteamAPI_ISteamFriends_GetPersonaName"); + SYM(pActivateOverlay, "SteamAPI_ISteamFriends_ActivateGameOverlay"); + SYM(pActivateOverlayWeb, "SteamAPI_ISteamFriends_ActivateGameOverlayToWebPage"); + SYM(pSetRichPresence, "SteamAPI_ISteamFriends_SetRichPresence"); + SYM(pClearRichPresence, "SteamAPI_ISteamFriends_ClearRichPresence"); + SYM(pSteamId, "SteamAPI_ISteamUser_GetSteamID"); + SYM(pAppId, "SteamAPI_ISteamUtils_GetAppID"); + SYM(pOnDeck, "SteamAPI_ISteamUtils_IsSteamRunningOnSteamDeck"); + SYM(pBigPicture, "SteamAPI_ISteamUtils_IsSteamInBigPictureMode"); + SYM(pFloatingKeyboard, "SteamAPI_ISteamUtils_ShowFloatingGamepadTextInput"); + SYM(pDlcInstalled, "SteamAPI_ISteamApps_BIsDlcInstalled"); + SYM(pLanguage, "SteamAPI_ISteamApps_GetCurrentGameLanguage"); + SYM(pFileWrite, "SteamAPI_ISteamRemoteStorage_FileWrite"); + SYM(pFileRead, "SteamAPI_ISteamRemoteStorage_FileRead"); + SYM(pFileSize, "SteamAPI_ISteamRemoteStorage_GetFileSize"); + SYM(pFileExists, "SteamAPI_ISteamRemoteStorage_FileExists"); + SYM(pFileDelete, "SteamAPI_ISteamRemoteStorage_FileDelete"); + SYM(pCloudAccount, "SteamAPI_ISteamRemoteStorage_IsCloudEnabledForAccount"); + SYM(pCloudApp, "SteamAPI_ISteamRemoteStorage_IsCloudEnabledForApp"); + /* Since SDK 1.61 Steam loads the user's stats before the game starts, so + * the accessors work at once; the request only matters to an older + * libsteam_api, which the builders never bundle. */ + if (pRequestCurrentStats != NULL && stats != NULL) { + pRequestCurrentStats(stats); + } + state = 1; + return 1; +} + +static int steamReady(void) { + int r; + pthread_mutex_lock(&steamLock); + r = steamLoadLocked(); + pthread_mutex_unlock(&steamLock); + return r; +} + +/* stringToUTF8 reuses one buffer per thread; a call taking two strings must copy + * the first before converting the second. */ +static char* steamDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { + const char* u = s == JAVA_NULL ? "" : stringToUTF8(threadStateData, s); + return strdup(u == NULL ? "" : u); +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamInit___R_boolean(CODENAME_ONE_THREAD_STATE) { + return steamReady() ? JAVA_TRUE : JAVA_FALSE; +} + +/* True when the process should exit because Steam is relaunching it through the + * Steam client (the game was started from outside Steam). Valve answers false + * whenever a steam_appid.txt sits beside the executable, which is how local + * development runs stay put. Loads the library without initializing it. appId + * 0 reads CN1_STEAM_APPID. */ +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamRestartIfNecessary___int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_INT appId) { + void* h; + sbool (*fn)(uint32_t); + if (appId <= 0) { + /* The Steam target's launch.sh exports the app id. */ + const char* env = getenv("CN1_STEAM_APPID"); + appId = env != NULL ? atoi(env) : 0; + } + if (appId <= 0) { + return JAVA_FALSE; + } + pthread_mutex_lock(&steamLock); + if (lib == NULL) { + lib = steamOpen(); + } + h = lib; + pthread_mutex_unlock(&steamLock); + if (h == NULL) { + return JAVA_FALSE; + } + *(void**) (&fn) = dlsym(h, "SteamAPI_RestartAppIfNecessary"); + return fn != NULL && fn((uint32_t) appId) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_steamShutdown__(CODENAME_ONE_THREAD_STATE) { + pthread_mutex_lock(&steamLock); + if (state == 1 && pShutdown != NULL) { + pShutdown(); + } + state = -1; + pthread_mutex_unlock(&steamLock); +} + +/* Runs one frame of callbacks; answers the first event of interest, or 0. */ +JAVA_INT com_codename1_impl_linux_LinuxNative_steamPump___R_int(CODENAME_ONE_THREAD_STATE) { + SteamCallbackMsg msg; + int ev = 0; + pthread_mutex_lock(&steamLock); + if (state != 1 || pRunFrame == NULL || pNextCallback == NULL || pFreeLastCallback == NULL) { + pthread_mutex_unlock(&steamLock); + return 0; + } + pRunFrame(pipe_); + while (pNextCallback(pipe_, &msg)) { + if (ev == 0 && msg.callback == CB_GAME_OVERLAY_ACTIVATED && msg.paramSize > 0) { + ev = msg.param[0] ? EV_OVERLAY_SHOWN : EV_OVERLAY_HIDDEN; + } else if (ev == 0 && msg.callback == CB_FLOATING_KEYBOARD_DISMISSED) { + ev = EV_KEYBOARD_DISMISSED; + } + pFreeLastCallback(pipe_); + if (ev != 0) { + break; + } + } + pthread_mutex_unlock(&steamLock); + return ev; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamAchievement___java_lang_String_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_BOOLEAN set) { + const char* n; + if (!steamReady() || stats == NULL || name == JAVA_NULL) { + return JAVA_FALSE; + } + n = stringToUTF8(threadStateData, name); + if (set) { + return pSetAchievement != NULL && pSetAchievement(stats, n) ? JAVA_TRUE : JAVA_FALSE; + } + return pClearAchievement != NULL && pClearAchievement(stats, n) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamIsAchieved___java_lang_String_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { + sbool got = 0; + if (!steamReady() || stats == NULL || pGetAchievement == NULL || name == JAVA_NULL) { + return JAVA_FALSE; + } + return pGetAchievement(stats, stringToUTF8(threadStateData, name), &got) && got ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamStoreStats___R_boolean(CODENAME_ONE_THREAD_STATE) { + if (!steamReady() || stats == NULL || pStoreStats == NULL) { + return JAVA_FALSE; + } + return pStoreStats(stats) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_steamGetStatInt___java_lang_String_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_INT def) { + int32_t v = def; + if (steamReady() && stats != NULL && pGetStatInt != NULL && name != JAVA_NULL + && pGetStatInt(stats, stringToUTF8(threadStateData, name), &v)) { + return v; + } + return def; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamSetStatInt___java_lang_String_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_INT value) { + if (!steamReady() || stats == NULL || pSetStatInt == NULL || name == JAVA_NULL) { + return JAVA_FALSE; + } + return pSetStatInt(stats, stringToUTF8(threadStateData, name), value) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_FLOAT com_codename1_impl_linux_LinuxNative_steamGetStatFloat___java_lang_String_float_R_float(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_FLOAT def) { + float v = def; + if (steamReady() && stats != NULL && pGetStatFloat != NULL && name != JAVA_NULL + && pGetStatFloat(stats, stringToUTF8(threadStateData, name), &v)) { + return v; + } + return def; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamSetStatFloat___java_lang_String_float_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_FLOAT value) { + if (!steamReady() || stats == NULL || pSetStatFloat == NULL || name == JAVA_NULL) { + return JAVA_FALSE; + } + return pSetStatFloat(stats, stringToUTF8(threadStateData, name), value) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_steamPersonaName___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + const char* n = steamReady() && friends != NULL && pPersonaName != NULL ? pPersonaName(friends) : NULL; + return newStringFromCString(threadStateData, n == NULL ? "" : n); +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_steamUserId___R_long(CODENAME_ONE_THREAD_STATE) { + return steamReady() && user != NULL && pSteamId != NULL ? (JAVA_LONG) pSteamId(user) : 0; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_steamAppId___R_int(CODENAME_ONE_THREAD_STATE) { + return steamReady() && utils != NULL && pAppId != NULL ? (JAVA_INT) pAppId(utils) : 0; +} + +/* Bit 1: running on a Steam Deck; bit 2: Big Picture / Gaming Mode. */ +JAVA_INT com_codename1_impl_linux_LinuxNative_steamDeviceFlags___R_int(CODENAME_ONE_THREAD_STATE) { + int f = 0; + if (steamReady() && utils != NULL) { + if (pOnDeck != NULL && pOnDeck(utils)) { + f |= 1; + } + if (pBigPicture != NULL && pBigPicture(utils)) { + f |= 2; + } + } + return f; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_steamOverlay___java_lang_String_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT target, JAVA_BOOLEAN webPage) { + const char* t; + if (!steamReady() || friends == NULL || target == JAVA_NULL) { + return; + } + t = stringToUTF8(threadStateData, target); + if (webPage) { + if (pActivateOverlayWeb != NULL) { + pActivateOverlayWeb(friends, t, 0); + } + } else if (pActivateOverlay != NULL) { + pActivateOverlay(friends, t); + } +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamRichPresence___java_lang_String_java_lang_String_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT key, JAVA_OBJECT value) { + char* k; + JAVA_BOOLEAN r; + if (!steamReady() || friends == NULL) { + return JAVA_FALSE; + } + if (key == JAVA_NULL) { + if (pClearRichPresence != NULL) { + pClearRichPresence(friends); + } + return JAVA_TRUE; + } + if (pSetRichPresence == NULL) { + return JAVA_FALSE; + } + k = steamDup(threadStateData, key); + r = pSetRichPresence(friends, k, value == JAVA_NULL ? NULL : stringToUTF8(threadStateData, value)) + ? JAVA_TRUE : JAVA_FALSE; + free(k); + return r; +} + +/* mode 0 single line, 1 multiple lines, 2 email, 3 numeric. */ +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamFloatingKeyboard___int_int_int_int_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_INT mode, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { + if (!steamReady() || utils == NULL || pFloatingKeyboard == NULL) { + return JAVA_FALSE; + } + return pFloatingKeyboard(utils, mode, x, y, w, h) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamDlcInstalled___int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_INT appId) { + return steamReady() && apps != NULL && pDlcInstalled != NULL && pDlcInstalled(apps, (uint32_t) appId) + ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_steamLanguage___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + const char* l = steamReady() && apps != NULL && pLanguage != NULL ? pLanguage(apps) : NULL; + return newStringFromCString(threadStateData, l == NULL ? "" : l); +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamCloudEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) { + if (!steamReady() || storage == NULL || pCloudAccount == NULL || pCloudApp == NULL) { + return JAVA_FALSE; + } + return pCloudAccount(storage) && pCloudApp(storage) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamCloudWrite___java_lang_String_byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_OBJECT data) { + int len; + if (!steamReady() || storage == NULL || pFileWrite == NULL || name == JAVA_NULL || data == JAVA_NULL) { + return JAVA_FALSE; + } + len = (int) (*(JAVA_ARRAY) data).length; + return pFileWrite(storage, stringToUTF8(threadStateData, name), CN1_ARRAY_DATA(data), len) + ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_steamCloudRead___java_lang_String_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { + char* n; + int32_t size, got; + void* buf; + JAVA_OBJECT out; + if (!steamReady() || storage == NULL || pFileSize == NULL || pFileRead == NULL + || pFileExists == NULL || name == JAVA_NULL) { + return JAVA_NULL; + } + n = steamDup(threadStateData, name); + if (!pFileExists(storage, n)) { + free(n); + return JAVA_NULL; + } + size = pFileSize(storage, n); + if (size < 0) { + free(n); + return JAVA_NULL; + } + buf = malloc(size > 0 ? (size_t) size : 1); + if (buf == NULL) { + free(n); + return JAVA_NULL; + } + got = size > 0 ? pFileRead(storage, n, buf, size) : 0; + free(n); + out = got == size ? cn1LinuxNewByteArray(threadStateData, buf, size) : JAVA_NULL; + free(buf); + return out; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamCloudExists___java_lang_String_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { + return steamReady() && storage != NULL && pFileExists != NULL && name != JAVA_NULL + && pFileExists(storage, stringToUTF8(threadStateData, name)) ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_steamCloudDelete___java_lang_String_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { + return steamReady() && storage != NULL && pFileDelete != NULL && name != JAVA_NULL + && pFileDelete(storage, stringToUTF8(threadStateData, name)) ? JAVA_TRUE : JAVA_FALSE; +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 14f84d87406..d217227c2b7 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -756,6 +756,17 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in g_signal_connect(cn1Window, "delete-event", G_CALLBACK(cn1OnDelete), 0); cn1ResizeBackBuffer(cn1DisplayWidth, cn1DisplayHeight); + { + /* Fullscreen when asked (the steam target's launch.sh exports + * CN1_FULLSCREEN=1 per the steam.fullscreen hint) and always under + * Steam's Gaming Mode, where SteamGamepadUI is set and a window frame + * would only be letterboxed by gamescope. */ + const char* fs = getenv("CN1_FULLSCREEN"); + const char* gamepadUi = getenv("SteamGamepadUI"); + if ((fs != NULL && strcmp(fs, "1") == 0) || (gamepadUi != NULL && strcmp(gamepadUi, "1") == 0)) { + gtk_window_fullscreen(GTK_WINDOW(cn1Window)); + } + } gtk_widget_show_all(cn1Window); gtk_widget_grab_focus(cn1DrawingArea); cn1WindowOpen = 1; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java new file mode 100644 index 00000000000..3544d46e0ae --- /dev/null +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.linux; + +import com.codename1.gaming.GameServices; +import com.codename1.gaming.GameServicesBackend; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.TextArea; + +/// Steam as the `GameServices` backend. The natives are in `cn1_linux_steam.c`, +/// which loads `libsteam_api.so` from beside the executable at run time: an +/// application not started by Steam, or built without the library, is simply +/// "not available" and every call is a no-op. +/// +/// Callbacks (the overlay opening and closing, the floating keyboard closing) +/// are pumped on a thread of their own at about 30 Hz and delivered on the EDT. +final class LinuxGameServices extends GameServicesBackend { + // Mirrors EV_* in cn1_linux_steam.c. + private static final int EV_OVERLAY_SHOWN = 1; + private static final int EV_OVERLAY_HIDDEN = 2; + private static final int EV_KEYBOARD_DISMISSED = 3; + /// The field Steam's floating keyboard was opened for. EDT only. + private static Component keyboardTarget; + + private static final int DECK = 1; + private static final int BIG_PICTURE = 2; + + private boolean tried; + private boolean available; + + /// Installs the backend, first letting Steam relaunch an application that + /// was started outside it (the process then exits). + static void install() { + if (LinuxNative.steamRestartIfNecessary(0)) { + System.exit(0); + } + GameServices.setBackend(new LinuxGameServices()); + } + + public String getName() { + return "steam"; + } + + public boolean isAvailable() { + if (!tried) { + tried = true; + available = LinuxNative.steamInit(); + if (available) { + startPump(); + } + } + return available; + } + + private static void startPump() { + Thread t = new Thread(new Runnable() { + public void run() { + for (;;) { + final int ev = LinuxNative.steamPump(); + if (ev == EV_OVERLAY_SHOWN || ev == EV_OVERLAY_HIDDEN) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + GameServices.firePlatformUIChanged(ev == EV_OVERLAY_SHOWN); + } + }); + continue; + } + if (ev == EV_KEYBOARD_DISMISSED) { + // Closing the floating keyboard is the player saying "done": + // end the edit, or the field stays editing with no way to type. + Display.getInstance().callSerially(new Runnable() { + public void run() { + Component c = keyboardTarget; + keyboardTarget = null; + if (c != null && Display.getInstance().isTextEditing(c)) { + Display.getInstance().stopEditing(c); + } + } + }); + continue; + } + try { + Thread.sleep(33); + } catch (InterruptedException e) { + return; + } + } + } + }, "Steam callbacks"); + t.setDaemon(true); + t.start(); + } + + public String getPlayerId() { + long id = LinuxNative.steamUserId(); + return id == 0 ? "" : String.valueOf(id); + } + + public String getPlayerName() { + return LinuxNative.steamPersonaName(); + } + + public boolean setAchievement(String id, boolean unlocked) { + return LinuxNative.steamAchievement(id, unlocked); + } + + public boolean isAchievementUnlocked(String id) { + return LinuxNative.steamIsAchieved(id); + } + + public int getStatInt(String id, int def) { + return LinuxNative.steamGetStatInt(id, def); + } + + public boolean setStatInt(String id, int value) { + return LinuxNative.steamSetStatInt(id, value); + } + + public float getStatFloat(String id, float def) { + return LinuxNative.steamGetStatFloat(id, def); + } + + public boolean setStatFloat(String id, float value) { + return LinuxNative.steamSetStatFloat(id, value); + } + + public boolean commit() { + return LinuxNative.steamStoreStats(); + } + + public boolean isCloudSaveAvailable() { + return LinuxNative.steamCloudEnabled(); + } + + public boolean writeSave(String name, byte[] data) { + return LinuxNative.steamCloudWrite(name, data); + } + + public byte[] readSave(String name) { + return LinuxNative.steamCloudRead(name); + } + + public boolean hasSave(String name) { + return LinuxNative.steamCloudExists(name); + } + + public boolean deleteSave(String name) { + return LinuxNative.steamCloudDelete(name); + } + + public boolean setPresence(String key, String value) { + return LinuxNative.steamRichPresence(key, value); + } + + /// Steam add-ons are DLC app ids. + public boolean ownsContent(String id) { + int appId = parseId(id); + return appId > 0 && LinuxNative.steamDlcInstalled(appId); + } + + public boolean openPlatformUI(String target) { + boolean web = target.regionMatches(true, 0, "https://", 0, 8) + || target.regionMatches(true, 0, "http://", 0, 7); + LinuxNative.steamOverlay(target, web); + return true; + } + + public boolean isControllerFirst() { + return (LinuxNative.steamDeviceFlags() & (DECK | BIG_PICTURE)) != 0; + } + + public String getLanguage() { + return LinuxNative.steamLanguage(); + } + + /// Called as a text field starts editing: on a controller-first interface + /// there is no keyboard, so Steam's floating one is shown over the field. It + /// types into the focused editor as ordinary key events. + static void onEditStarted(Component cmp, boolean singleLine, int constraint) { + if (!GameServices.isAvailable() || !GameServices.isControllerFirst()) { + return; + } + int mode; + // The low 16 bits are the kind; the high ones are flags (PASSWORD...). + int kind = constraint & 0xffff; + if (kind == TextArea.NUMERIC || kind == TextArea.DECIMAL || kind == TextArea.PHONENUMBER) { + mode = 3; + } else if (kind == TextArea.EMAILADDR) { + mode = 2; + } else { + mode = singleLine ? 0 : 1; + } + keyboardTarget = cmp; + // Steam wants screen coordinates. Controller-first means Gaming Mode, + // where gamescope shows the window fullscreen at the origin, and the + // port draws in device pixels, so component coordinates are already + // screen coordinates there. On a desktop the keyboard is never shown. + LinuxNative.steamFloatingKeyboard(mode, cmp.getAbsoluteX(), cmp.getAbsoluteY(), + cmp.getWidth(), cmp.getHeight()); + } + + private static int parseId(String id) { + int v = 0; + if (id == null || id.length() == 0 || id.length() > 9) { + return -1; + } + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + if (c < '0' || c > '9') { + return -1; + } + v = v * 10 + (c - '0'); + } + return v; + } +} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 0cc8d6f9613..5cd5d853eaa 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -133,6 +133,13 @@ public boolean isScreenReaderEnabled() { /// do not drift apart. private static final int EVENT_MENU_COMMAND = 23; private static final int EVENT_THEME_SETTINGS_CHANGED = 24; + /// Game controllers; must match CN1_EVENT_GAMEPAD_* in cn1_linux.h. + private static final int EVENT_GAMEPAD_CONNECTED = 25; + private static final int EVENT_GAMEPAD_DISCONNECTED = 26; + private static final int EVENT_GAMEPAD_BUTTON = 27; + private static final int EVENT_GAMEPAD_AXIS = 28; + /// CN1_GAMEPAD_FIXED: the fixed-point scale of an axis value. + private static final float GAMEPAD_FIXED = 10000f; private static final int EVENT_ROTATE = 11; private static final int EVENT_ACCESSIBILITY_ACTION = 12; // Additional desktop windows. These always carry a non-zero window id. @@ -376,6 +383,10 @@ public void init(Object m) { // random bytes, digests and locale symbols through Codename One. com.codename1.impl.CoreJdkHostInstaller.install(); LinuxNative.initDisplay("Codename One", defaultWindowWidth, defaultWindowHeight); + // Controllers reach Gamepads through the input ring like every other input. + LinuxNative.gamepadStart(); + // Steam, when the application was started by it (and a no-op otherwise). + LinuxGameServices.install(); windowGraphicsPeer = LinuxNative.getWindowGraphics(); windowGraphics = Long.valueOf(windowGraphicsPeer); defaultFont = Long.valueOf(LinuxNative.getDefaultFont()); @@ -1216,6 +1227,21 @@ private void drainInput() { // made hover unreachable there. dispatchPointerHover(windowId, x, y, key); break; + case EVENT_GAMEPAD_CONNECTED: + // The name is read now, not when the event was queued: a pad + // unplugged in between reports "" and its DISCONNECTED follows + // in this same drain, so the label is only ever briefly empty. + com.codename1.gaming.Gamepads.reportConnected(x, LinuxNative.gamepadName(x)); + break; + case EVENT_GAMEPAD_DISCONNECTED: + com.codename1.gaming.Gamepads.reportDisconnected(x); + break; + case EVENT_GAMEPAD_BUTTON: + com.codename1.gaming.Gamepads.reportButton(x, y, key != 0); + break; + case EVENT_GAMEPAD_AXIS: + com.codename1.gaming.Gamepads.reportAxis(x, y, key / GAMEPAD_FIXED); + break; case EVENT_KEY_PRESSED: windowKeyPressed(windowId, keyCodeOf(key)); break; @@ -2572,6 +2598,7 @@ public void editString(final Component cmp, int maxSize, int constraint, String } boolean singleLine = !(cmp instanceof com.codename1.ui.TextArea) || ((com.codename1.ui.TextArea) cmp).isSingleLineTextArea(); + LinuxGameServices.onEditStarted(cmp, singleLine, constraint); com.codename1.ui.plaf.Style s = cmp.getStyle(); int padL = s.getPaddingLeft(false); @@ -3091,6 +3118,8 @@ public int getGameAction(int keyCode) { return Display.GAME_LEFT; case GAME_KEY_CODE_RIGHT: return Display.GAME_RIGHT; + case GAME_KEY_CODE_FIRE: + return Display.GAME_FIRE; default: return 0; } @@ -3107,6 +3136,8 @@ public int getKeyCode(int gameAction) { return GAME_KEY_CODE_LEFT; case Display.GAME_RIGHT: return GAME_KEY_CODE_RIGHT; + case Display.GAME_FIRE: + return GAME_KEY_CODE_FIRE; default: return 0; } @@ -3120,6 +3151,15 @@ public int getKeyCode(int gameAction) { private static final int GAME_KEY_CODE_DOWN = -92; private static final int GAME_KEY_CODE_LEFT = -93; private static final int GAME_KEY_CODE_RIGHT = -94; + // No key produces this one: it is what a controller's A button presses + // (Gamepads asks getKeyCode(GAME_FIRE)), so that A activates the focused + // component. Answering 0 for GAME_FIRE made A do nothing. + private static final int GAME_KEY_CODE_FIRE = -90; + + @Override + public void rumbleGamepad(int index, float strong, float weak, int durationMillis) { + LinuxNative.gamepadRumble(index, (int) (strong * 65535f), (int) (weak * 65535f), durationMillis); + } /// The Codename One code of a key the window reported. /// diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index b00d4ad8a70..081b530632c 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -210,6 +210,98 @@ public static native void setTransform(long graphics, float m00, float m10, floa */ public static native boolean pollEvent(int[] out); + /** + * Starts the game controller reader (cn1_linux_gamepad.c) once; later calls do + * nothing. Controllers then arrive through {@link #pollEvent} as the + * CN1_EVENT_GAMEPAD_* events. + */ + public static native void gamepadStart(); + + /** The device name of the controller in a slot, or an empty string. */ + public static native String gamepadName(int index); + + /** Rumbles a controller; motors 0..65535. Best effort. */ + public static native void gamepadRumble(int index, int strong, int weak, int millis); + + // Steamworks (cn1_linux_steam.c), backing LinuxGameServices. With no + // libsteam_api.so beside the executable, or no Steam client, steamInit is + // false and every other call answers its default. + + /** Connects to the Steam client once; true when connected. */ + public static native boolean steamInit(); + + /** True when Steam is relaunching the app and this process must exit; 0 reads CN1_STEAM_APPID. */ + public static native boolean steamRestartIfNecessary(int appId); + + /** Disconnects from Steam. */ + public static native void steamShutdown(); + + /** Runs one frame of callbacks; answers an EV_* event code or 0. */ + public static native int steamPump(); + + /** Unlocks (set) or relocks an achievement. */ + public static native boolean steamAchievement(String name, boolean set); + + /** Whether an achievement is unlocked. */ + public static native boolean steamIsAchieved(String name); + + /** Sends stats and achievements to the server. */ + public static native boolean steamStoreStats(); + + /** Reads an int stat. */ + public static native int steamGetStatInt(String name, int def); + + /** Writes an int stat. */ + public static native boolean steamSetStatInt(String name, int value); + + /** Reads a float stat. */ + public static native float steamGetStatFloat(String name, float def); + + /** Writes a float stat. */ + public static native boolean steamSetStatFloat(String name, float value); + + /** The player's persona name. */ + public static native String steamPersonaName(); + + /** The player's 64 bit Steam id. */ + public static native long steamUserId(); + + /** The running app id. */ + public static native int steamAppId(); + + /** Bit 1 Steam Deck, bit 2 Big Picture. */ + public static native int steamDeviceFlags(); + + /** Opens the overlay at a dialog, or a web page. */ + public static native void steamOverlay(String target, boolean webPage); + + /** Sets a rich presence key; a null key clears all. */ + public static native boolean steamRichPresence(String key, String value); + + /** Shows the floating gamepad keyboard over a rectangle. */ + public static native boolean steamFloatingKeyboard(int mode, int x, int y, int w, int h); + + /** Whether a DLC is installed. */ + public static native boolean steamDlcInstalled(int appId); + + /** The game language chosen in Steam. */ + public static native String steamLanguage(); + + /** Whether Steam Cloud is on for the account and the app. */ + public static native boolean steamCloudEnabled(); + + /** Writes a Steam Cloud file. */ + public static native boolean steamCloudWrite(String name, byte[] data); + + /** Reads a Steam Cloud file, or null. */ + public static native byte[] steamCloudRead(String name); + + /** Whether a Steam Cloud file exists. */ + public static native boolean steamCloudExists(String name); + + /** Deletes a Steam Cloud file. */ + public static native boolean steamCloudDelete(String name); + // ---- additional desktop windows (cn1_linux_desktopwindow.c) -------------- // // A window is addressed by the slot index returned from desktopWindowCreate. diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index ecd685c309a..786e137b801 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -928,9 +928,11 @@ private void drainInput() { dispatchPointerHover(windowId, x, y, key); break; case EVENT_KEY_PRESSED: + reportGamepadKey(key, true); windowKeyPressed(windowId, key); break; case EVENT_KEY_RELEASED: + reportGamepadKey(key, false); windowKeyReleased(windowId, key); break; case EVENT_SIZE_CHANGED: @@ -2771,6 +2773,45 @@ public int getBackspaceKeyCode() { private static final int VK_GAMEPAD_LEFT_THUMBSTICK_RIGHT = 0xD5; private static final int VK_GAMEPAD_LEFT_THUMBSTICK_LEFT = 0xD6; + /// The Gamepad.BUTTON_* a gamepad virtual key stands for, or -1. The stick + /// directions are left out: they are a direction, not a button, and the + /// key events already navigate with them. + static int gamepadButtonOf(int vk) { + switch (vk) { + case VK_GAMEPAD_A: return com.codename1.gaming.Gamepad.BUTTON_A; + case VK_GAMEPAD_B: return com.codename1.gaming.Gamepad.BUTTON_B; + case 0xC5: return com.codename1.gaming.Gamepad.BUTTON_X; + case 0xC6: return com.codename1.gaming.Gamepad.BUTTON_Y; + case 0xC7: return com.codename1.gaming.Gamepad.BUTTON_RIGHT_SHOULDER; + case 0xC8: return com.codename1.gaming.Gamepad.BUTTON_LEFT_SHOULDER; + case 0xC9: return com.codename1.gaming.Gamepad.BUTTON_LEFT_TRIGGER; + case 0xCA: return com.codename1.gaming.Gamepad.BUTTON_RIGHT_TRIGGER; + case VK_GAMEPAD_DPAD_UP: return com.codename1.gaming.Gamepad.BUTTON_DPAD_UP; + case VK_GAMEPAD_DPAD_DOWN: return com.codename1.gaming.Gamepad.BUTTON_DPAD_DOWN; + case VK_GAMEPAD_DPAD_LEFT: return com.codename1.gaming.Gamepad.BUTTON_DPAD_LEFT; + case VK_GAMEPAD_DPAD_RIGHT: return com.codename1.gaming.Gamepad.BUTTON_DPAD_RIGHT; + case 0xCF: return com.codename1.gaming.Gamepad.BUTTON_START; + case 0xD0: return com.codename1.gaming.Gamepad.BUTTON_BACK; + case 0xD1: return com.codename1.gaming.Gamepad.BUTTON_LEFT_STICK; + case 0xD2: return com.codename1.gaming.Gamepad.BUTTON_RIGHT_STICK; + default: return -1; + } + } + + /// The Xbox build gets the controller as keys and only as keys, so it is + /// reported to Gamepads from them, as one controller in slot 0. Navigation + /// stays with the key event itself, which is why `navigate` is false. + private static void reportGamepadKey(int vk, boolean down) { + int button = gamepadButtonOf(vk); + if (button < 0) { + return; + } + if (com.codename1.gaming.Gamepads.getGamepad(0) == null) { + com.codename1.gaming.Gamepads.reportConnected(0, "Gamepad"); + } + com.codename1.gaming.Gamepads.reportButton(0, button, down, false); + } + /// B on a controller, the console's universal "back". A Win32 window never /// receives it, so on the desktop this changes nothing. @Override diff --git a/docs/developer-guide/Gaming-Consoles.asciidoc b/docs/developer-guide/Gaming-Consoles.asciidoc index c3646a30c48..aa9ba2f6057 100644 --- a/docs/developer-guide/Gaming-Consoles.asciidoc +++ b/docs/developer-guide/Gaming-Consoles.asciidoc @@ -53,3 +53,8 @@ and PlayStation and Nintendo consoles likewise require their platform holder's SDK. The portable parts above (the windowless host, controller mapping, safe area, and game code from <> or `com.codename1.gaming`) are what such a port would reuse. + +=== Steam + +The Steam Deck and Steam Machine aren't closed consoles: they run Linux, and Steam +accepts the native Linux build. See <>. diff --git a/docs/developer-guide/Steam.asciidoc b/docs/developer-guide/Steam.asciidoc new file mode 100644 index 00000000000..385ebd1bb76 --- /dev/null +++ b/docs/developer-guide/Steam.asciidoc @@ -0,0 +1,175 @@ +[#steam] +== Publishing to Steam + +Steam reaches Linux desktops, the Steam Deck and the Steam Machine, which all run +the same binary. Codename One publishes to it with the +<<_working_with_the_native_linux_port,native Linux port>>: one native executable +with no JVM, packaged as a Steam *depot* and uploaded with SteamPipe. The same game +code builds for iOS, Android and the other targets unchanged. + +On top of the build, Codename One adds what a Steam title needs: + +* *Controllers.* Every gamepad Steam knows about, the Steam Deck's own controls + included, arrives through `com.codename1.gaming.Gamepads`. Forms can be driven + with a controller with no code at all. +* *Platform services.* Achievements, stats, cloud saves, rich presence, DLC + ownership and the Steam overlay are available through + `com.codename1.gaming.GameServices`. The same calls are no-ops on a platform + without game services, so the code stays portable. +* *Gaming Mode.* The app starts full screen, and text fields open Steam's + on-screen keyboard when the Deck or a TV is in controller mode. + +=== Controllers + +`Gamepads` keeps one `Gamepad` per connected controller, laid out like an Xbox +controller: *A*, *B*, *X* and *Y*, the bumpers and triggers, two sticks, the D-pad +and the menu buttons. + +[source,java] +---- +Gamepad pad = Gamepads.getGamepad(0); +if (pad != null && pad.isConnected()) { + float x = pad.getAxis(Gamepad.AXIS_LEFT_X); // -1..1, dead zone applied + if (pad.isButtonDown(Gamepad.BUTTON_A)) { + jump(); + } +} +---- + +`GameInput` reads the first controller too: `wasGamepadButtonPressed` gives the +press of this frame, and `getAxisX`/`getAxisY` fall back to the left stick when the +on-screen joystick is idle, so a game written for touch plays with a controller +unchanged. + +For events rather than polling, add a `GamepadListener`. Return `true` from +`gamepadButton` to consume the press. + +==== Navigating forms + +With no code, the D-pad and left stick move the focus between components, *A* +presses the focused one and *B* runs the form's back command. A game that reads the +controller itself calls `Gamepads.setFocusNavigation(false)`. + +On Linux the port reads controllers from the kernel's input devices, so nothing +needs installing. Under Steam, Steam Input presents every controller as an Xbox +pad, and the port skips the devices Steam hides so a controller never appears twice. +`Gamepad.rumble()` drives the motors where the controller has them. + +=== Game services + +[source,java] +---- +if (GameServices.isAvailable()) { // true on Steam + GameServices.unlockAchievement("FIRST_WIN"); + GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); + GameServices.commit(); + GameServices.writeSave("slot1", saveBytes); +} +---- + +* *Achievements and stats* use the API names defined on the Steamworks site. + `commit()` sends them to Steam. +* *Cloud saves* use Steam Cloud. Turn on Steam Cloud for the app in Steamworks and + give it a byte quota. +* *Presence:* `setPresence(key, value)` sets rich presence, such as + `"steam_display"`. +* *Ownership:* `ownsContent("1234")` answers whether the player owns that DLC. +* *Overlay:* `openPlatformUI("achievements")` opens the overlay on a page, and an + `http`/`https` target opens the overlay's browser. A `GameServicesListener` is + told when the overlay opens and closes, which is the moment to pause. +* `isControllerFirst()` is true on the Deck and in Big Picture mode, where the UI + should assume a controller and a TV-distance screen. + +The Steamworks library is loaded when the app starts. If it's missing, or Steam +isn't running, `isAvailable()` is false and every call does nothing. + +=== Setting up the app in Steamworks + +. Create the app on the https://partner.steamgames.com[Steamworks partner site] + and note its *App ID*. Steamworks creates a depot with the next number. +. Under *Installation > General*, add a launch option with the executable + `launch.sh` and the operating system *Linux + SteamOS*. +. Under *SteamPipe > Depots*, set the depot's operating system to Linux. +. Download the Steamworks SDK. The build copies + `redistributable_bin/linux64/libsteam_api.so` from it into the depot. Steam + allows that library to be redistributed; nothing else from the SDK is used. + +=== Building + +Add the hints to `codenameone_settings.properties`: + +[source,properties] +---- +codename1.arg.steam.appId=480 +codename1.arg.steam.sdkPath=/path/to/steamworks_sdk +---- + +and build: + +[source,bash] +---- +mvn cn1:buildSteamLocal # or ./gradlew buildSteamLocal +---- + +The SDK path can also come from the `STEAMWORKS_SDK` environment variable. Without +an SDK the depot is still built, and `GameServices` reports itself unavailable. + +The result is in `linux/target/steam`: + +---- +content/ the depot + MyGame the executable + launch.sh the launch option + libsteam_api.so + steam_appid.txt for running outside Steam; not uploaded +scripts/ + app_build_480.vdf + depot_build_481.vdf +---- + +`content/launch.sh` runs the game outside Steam too, which is the quickest way to +test achievements against a running Steam client. + +[cols="1,1,3"] +|=== +|Hint |Default |Meaning + +|`steam.appId` | |The App ID. Required. +|`steam.depotId` |App ID + 1 |The Linux depot. +|`steam.branch` |empty |A beta branch to set the upload live on. +|`steam.buildDescription` |name and version |The description shown in Steamworks. +|`steam.sdkPath` |`STEAMWORKS_SDK` |The Steamworks SDK directory. +|`steam.fullscreen` |`true` |Start full screen. +|=== + +=== Uploading + +With SteamCMD installed: + +[source,bash] +---- +mvn cn1:steamUpload -Dsteam.user=my_build_account +---- + +The first run asks for the password and the Steam Guard code; later runs reuse +SteamCMD's saved login. + +A cloud build (`mvn cn1:buildSteam`) can be uploaded for you instead. Save the +Steam builder account and its `config.vdf` under *Submissions* in the build +console, then press *Submit to Steam* on the finished build, or turn on +automatic submission for the Linux target to upload every build that produces a +depot. + +Valve allows an upload to go live on a *beta branch* only. Set `steam.branch` to +test a build on a branch, then make it live on the default branch, and submit it +for review, from the Steamworks site. + +=== Runtime + +Steam runs Linux games inside the Steam Linux Runtime, a container with a fixed set +of libraries. The native Linux port runs in it as is, with these exceptions: + +* `BrowserComponent` is unavailable: the runtime has no WebKitGTK. +* Media playback through GStreamer is unavailable. +* Secure storage is unavailable: the runtime has no libsecret. +* Cryptography uses the runtime's OpenSSL 1.1 when OpenSSL 3 is absent. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 87aeff8ab5c..bf9ec0695f9 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -312,6 +312,8 @@ include::Working-With-Linux.asciidoc[] include::Embedded-Linux.asciidoc[] +include::Steam.asciidoc[] + include::Realtime.asciidoc[] include::Desktop-Integration.asciidoc[] diff --git a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 5d818bfaf4a..59fd03b260c 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -92,6 +92,18 @@ public class LinuxNativeBuilder extends Executor { private File resultDir; private File linuxExecutable; + private boolean steamPackaging; + private File steamDepot; + + /// Lays the result out as a Steam depot as well (the `steam` targets). + public void setSteamPackaging(boolean steamPackaging) { + this.steamPackaging = steamPackaging; + } + + /// The zipped Steam depot, or null when Steam packaging was off. + public File getSteamDepot() { + return steamDepot; + } /** The produced native executable, or {@code null} if the build failed. */ public File getLinuxExecutable() { @@ -571,6 +583,23 @@ public void usesClassMethod(String cls, String method) { } } log("Native Linux executable: " + linuxExecutable.getAbsolutePath() + " (" + arch + ")"); + if (steamPackaging || "true".equalsIgnoreCase(request.getArg("steam.package", "false"))) { + SteamDepotPackager.Input in = new SteamDepotPackager.Input(resultDir, linuxExecutable, + SteamDepotPackager.appIdOf(request.getArg("steam.appId", null))); + in.depotId = request.getArg("steam.depotId", null); + in.branch = request.getArg("steam.branch", ""); + in.description = request.getArg("steam.buildDescription", + request.getDisplayName() + " " + request.getVersion()); + in.fullscreen = !"false".equalsIgnoreCase(request.getArg("steam.fullscreen", "true")); + in.arch = arch; + String sdk = request.getArg("steam.sdkPath", System.getenv("STEAMWORKS_SDK")); + in.sdkDir = sdk == null || sdk.trim().isEmpty() ? null : new File(sdk.trim()); + try { + steamDepot = SteamDepotPackager.packageDepot(in, this::log); + } catch (com.codename1.builders.BuildException ex) { + throw new BuildException(ex.getMessage(), ex); + } + } return true; } diff --git a/maven/build-engine/src/main/java/com/codename1/builders/SteamDepotPackager.java b/maven/build-engine/src/main/java/com/codename1/builders/SteamDepotPackager.java new file mode 100644 index 00000000000..dc2435ae386 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/builders/SteamDepotPackager.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import org.apache.tools.zip.ZipEntry; +import org.apache.tools.zip.ZipOutputStream; + +/// Lays a native Linux build out as a Steam depot, ready for SteamPipe. +/// +/// ``` +/// steam/ +/// content/ the depot: uploaded as is +///
the executable +/// launch.sh the launch option to configure in Steamworks +/// libsteam_api.so from the Steamworks SDK, when one was supplied +/// lib*.so the libraries the build already bundles (libcn1ble) +/// steam_appid.txt for running locally; excluded from the upload +/// scripts/ +/// app_build_.vdf steamcmd +run_app_build scripts/app_build_.vdf +/// depot_build_.vdf +/// output/ SteamPipe's build cache and logs +/// ``` +/// +/// Steamworks is loaded by the application at run time (`cn1_linux_steam.c`), +/// so a depot packaged with no SDK still runs; it just has no `GameServices`. +/// Nothing from the SDK is compiled in, and only its redistributable library is +/// copied. +public final class SteamDepotPackager { + private SteamDepotPackager() { + } + + /// What the packager needs, gathered by the builder. + public static final class Input { + File resultDir; + File executable; + String appId; + String depotId; + String branch; + String description; + boolean fullscreen; + String arch; + File sdkDir; + + Input(File resultDir, File executable, String appId) { + this.resultDir = resultDir; + this.executable = executable; + this.appId = appId; + } + } + + /// The depot app id: the hint, else nothing (packaging refuses). + static String appIdOf(String hint) { + return hint == null ? null : hint.trim(); + } + + /// `steam.depotId`, defaulting to the app id + 1, the depot Steamworks + /// creates alongside a new app. + static String depotIdOf(String appId, String hint) { + if (hint != null && hint.trim().length() > 0) { + return hint.trim(); + } + // Steam ids are 32-bit; anything else is rejected by packageDepot with the hint's name. + if (appId == null || !appId.matches("[0-9]{1,10}")) { + return appId; + } + return String.valueOf(Long.parseLong(appId) + 1); + } + + /// Builds the depot tree under `resultDir/steam` and zips it to + /// `resultDir/
-steam.zip`, keeping the executable bits. Returns the zip. + static File packageDepot(Input in, Logger log) throws BuildException { + if (in.appId == null || !in.appId.matches("[0-9]{1,10}")) { + throw new BuildException("The steam target needs the steam.appId build hint " + + "(the numeric app id from the Steamworks partner site); it is '" + in.appId + "'"); + } + String depot = depotIdOf(in.appId, in.depotId); + if (!depot.matches("[0-9]{1,10}")) { + throw new BuildException("steam.depotId must be numeric; it is '" + depot + "'"); + } + File steam = new File(in.resultDir, "steam"); + if (in.branch != null && ("public".equalsIgnoreCase(in.branch.trim()) + || "default".equalsIgnoreCase(in.branch.trim()))) { + throw new BuildException("steam.branch cannot be the default branch: Valve sets it live only " + + "from the Steamworks site. Leave steam.branch empty, or name a beta branch."); + } + // A rebuild must not ship what the last one left behind (a library + // since removed, a libsteam_api.so from an SDK no longer configured). + deleteTree(steam); + File content = new File(steam, "content"); + File scripts = new File(steam, "scripts"); + File output = new File(steam, "output"); + content.mkdirs(); + scripts.mkdirs(); + output.mkdirs(); + String main = in.executable.getName(); + // Spliced into launch.sh unquoted-safe only for plain file names. + if (!main.matches("[A-Za-z0-9_.-]+")) { + throw new BuildException("The executable name cannot go in launch.sh: " + main); + } + try { + copy(in.executable, new File(content, main)); + new File(content, main).setExecutable(true, false); + File[] siblings = in.resultDir.listFiles(); + if (siblings != null) { + for (File f : siblings) { + if (f.isFile() && f.getName().endsWith(".so")) { + copy(f, new File(content, f.getName())); + } + } + } + File steamLib = steamApiLibrary(in.sdkDir, in.arch); + if (steamLib != null) { + copy(steamLib, new File(content, "libsteam_api.so")); + log.log("Steam: bundled " + steamLib.getAbsolutePath()); + } else { + log.log("Steam: no Steamworks SDK supplied (steam.sdkPath / STEAMWORKS_SDK); the depot is " + + "packaged without libsteam_api.so and GameServices will report unavailable."); + } + write(new File(content, "launch.sh"), launchScript(main, in.appId, in.fullscreen)); + new File(content, "launch.sh").setExecutable(true, false); + write(new File(content, "steam_appid.txt"), in.appId + "\n"); + write(new File(scripts, "depot_build_" + depot + ".vdf"), depotVdf(depot)); + write(new File(scripts, "app_build_" + in.appId + ".vdf"), + appVdf(in.appId, depot, in.description, in.branch)); + File zip = new File(in.resultDir, main + "-steam.zip"); + zipTree(steam, zip, main); + log.log("Steam depot: " + content.getAbsolutePath() + " (app " + in.appId + ", depot " + depot + ")"); + return zip; + } catch (IOException ex) { + throw new BuildException("Failed to package the Steam depot", ex); + } + } + + /// The SDK's redistributable library for the architecture, or null. + static File steamApiLibrary(File sdkDir, String arch) { + if (sdkDir == null) { + return null; + } + String sub = "arm64".equals(arch) ? "linuxarm64" : "linux64"; + String[] candidates = { + "redistributable_bin/" + sub + "/libsteam_api.so", + "sdk/redistributable_bin/" + sub + "/libsteam_api.so", + }; + for (String c : candidates) { + File f = new File(sdkDir, c); + if (f.isFile()) { + return f; + } + } + return null; + } + + static String launchScript(String main, String appId, boolean fullscreen) { + return "#!/bin/sh\n" + + "# The launch option to configure in Steamworks (Installation > General).\n" + + "HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n" + + "export LD_LIBRARY_PATH=\"$HERE${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\n" + + "export CN1_STEAM_APPID=" + appId + "\n" + + (fullscreen ? "export CN1_FULLSCREEN=1\n" : "") + + "exec \"$HERE/" + main + "\" \"$@\"\n"; + } + + static String depotVdf(String depot) { + return "\"DepotBuild\"\n{\n" + + "\t\"DepotID\" \"" + depot + "\"\n" + + "\t\"FileMapping\"\n\t{\n" + + "\t\t\"LocalPath\" \"*\"\n" + + "\t\t\"DepotPath\" \".\"\n" + + "\t\t\"Recursive\" \"1\"\n" + + "\t}\n" + + "\t\"FileExclusion\" \"steam_appid.txt\"\n" + + "\t\"FileExclusion\" \"*.debug\"\n" + + "}\n"; + } + + static String appVdf(String appId, String depot, String description, String branch) { + StringBuilder sb = new StringBuilder(); + sb.append("\"AppBuild\"\n{\n"); + sb.append("\t\"AppID\" \"").append(appId).append("\"\n"); + sb.append("\t\"Desc\" \"").append(vdfEscape(description == null ? "" : description)).append("\"\n"); + sb.append("\t\"ContentRoot\" \"../content/\"\n"); + sb.append("\t\"BuildOutput\" \"../output/\"\n"); + if (branch != null && branch.trim().length() > 0) { + // Valve refuses SetLive on the default branch from an upload. + sb.append("\t\"SetLive\" \"").append(vdfEscape(branch.trim())).append("\"\n"); + } + sb.append("\t\"Depots\"\n\t{\n"); + sb.append("\t\t\"").append(depot).append("\" \"depot_build_").append(depot).append(".vdf\"\n"); + sb.append("\t}\n}\n"); + return sb.toString(); + } + + static String vdfEscape(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n' || c == '\r') { + sb.append(' '); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /// Zips the tree with Unix modes, so the executable and launch.sh stay + /// executable wherever the archive is unpacked for upload. + private static void zipTree(File root, File zip, String main) throws IOException { + try (OutputStream fo = new FileOutputStream(zip); ZipOutputStream zo = new ZipOutputStream(fo)) { + addTree(zo, root, "steam/", main); + } + } + + private static void addTree(ZipOutputStream zo, File dir, String prefix, String main) throws IOException { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + java.util.Arrays.sort(files); + for (File f : files) { + String name = prefix + f.getName(); + if (f.isDirectory()) { + ZipEntry e = new ZipEntry(name + "/"); + e.setUnixMode(040755); + zo.putNextEntry(e); + zo.closeEntry(); + addTree(zo, f, name + "/", main); + continue; + } + ZipEntry e = new ZipEntry(name); + boolean exec = f.getName().equals(main) || f.getName().equals("launch.sh"); + e.setUnixMode(exec ? 0100755 : 0100644); + zo.putNextEntry(e); + try (InputStream is = new FileInputStream(f)) { + byte[] buf = new byte[65536]; + int n; + while ((n = is.read(buf)) > 0) { + zo.write(buf, 0, n); + } + } + zo.closeEntry(); + } + } + + private static void copy(File from, File to) throws IOException { + java.nio.file.Files.copy(from.toPath(), to.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + + private static void write(File f, String text) throws IOException { + try (Writer w = new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8)) { + w.write(text); + } + } + + /// The builder's log, so this class needs no Executor. + interface Logger { + void log(String message); + } + + /** Deletes without following links: a link inside is removed, never what it points at. */ + private static void deleteTree(File f) { + if (!java.nio.file.Files.isSymbolicLink(f.toPath())) { + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) { + deleteTree(k); + } + } + } + f.delete(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java index 91f4f45a743..617faba440a 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java @@ -2169,7 +2169,11 @@ private void createAntProject() throws IOException, LibraryPropertiesException, } else if ("local-linux-device".equals(buildTarget)) { // Native ParparVM Linux build (GTK3/Cairo, CMake/Ninja). Distinct // from the JVM-bundled "linux-desktop" (javase) target. - doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); + doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar, false); + } else if ("local-steam".equals(buildTarget)) { + // The native Linux build laid out as a Steam depot, with the + // SteamPipe scripts beside it (SteamDepotPackager). + doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar, true); } else if (buildTarget.contains("android") || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { doAndroidLocalBuild(antProject, cn1SettingsProps, antDistJar); } else if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) @@ -2971,9 +2975,11 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil * pkg-config present (and, for musl targets, a {@code zig}/musl toolchain); * elsewhere the builder fails fast with a clear message. */ - private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File distJar, boolean steam) + throws BuildExecutionException { File codenameOneJar = getJar("com.codenameone", "codenameone-core"); LinuxNativeBuilder e = new LinuxNativeBuilder(); + e.setSteamPackaging(steam); e.setLogger(getLog()); File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "linux-build"); e.setBuildDirectory(buildDirectory); @@ -3025,6 +3031,19 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File kept.setExecutable(true); getLog().info("Built native Linux executable: " + kept.getAbsolutePath()); } + if (e.getSteamDepot() != null) { + // Kept with the depot tree, so cn1:steamUpload can run the scripts. + File out = new File(host.buildDirectory(), "steam"); + try { + copyTree(new File(e.getResultDir(), "steam"), out); + java.nio.file.Files.copy(e.getSteamDepot().toPath(), + new File(host.buildDirectory(), e.getSteamDepot().getName()).toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (java.io.IOException ioe) { + throw new BuildExecutionException("Could not copy the Steam depot to " + out, ioe); + } + getLog().info("Steam depot: " + out.getAbsolutePath()); + } } catch (com.codename1.builders.BuildException hardeningEx) { throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx); } catch (org.apache.tools.ant.BuildException ex) { @@ -3038,6 +3057,26 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File } } + /// Copies a tree, keeping the executable bit (the depot's binary and launch.sh). + private static void copyTree(File from, File to) throws java.io.IOException { + to.mkdirs(); + File[] files = from.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + File t = new File(to, f.getName()); + if (f.isDirectory()) { + copyTree(f, t); + } else { + java.nio.file.Files.copy(f.toPath(), t.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + if (f.canExecute()) { + t.setExecutable(true, false); + } + } + } + } + /// `-Dcn1.board=rpi5` is the whole of an embedded build: it turns on /// `linux.embedded` and names the board, whose profile picks the architecture /// and the display defaults. An explicit `linux.embedded` hint is left alone. diff --git a/maven/build-engine/src/test/java/com/codename1/builders/SteamDepotPackagerTest.java b/maven/build-engine/src/test/java/com/codename1/builders/SteamDepotPackagerTest.java new file mode 100644 index 00000000000..982154cd200 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/SteamDepotPackagerTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.apache.tools.zip.ZipEntry; +import org.apache.tools.zip.ZipFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SteamDepotPackagerTest { + @TempDir + File tmp; + + private SteamDepotPackager.Input input(String appId) throws Exception { + File result = new File(tmp, "result"); + result.mkdirs(); + File exe = new File(result, "MyGame"); + Files.write(exe.toPath(), new byte[]{0x7f, 'E', 'L', 'F'}); + Files.write(new File(result, "libcn1ble.so").toPath(), new byte[]{1}); + SteamDepotPackager.Input in = new SteamDepotPackager.Input(result, exe, appId); + in.description = "My \"Game\" 1.0"; + in.fullscreen = true; + in.arch = "x64"; + return in; + } + + @Test + void laysOutDepotAndScripts() throws Exception { + SteamDepotPackager.Input in = input("480"); + File sdk = new File(tmp, "sdk/redistributable_bin/linux64"); + sdk.mkdirs(); + Files.write(new File(sdk, "libsteam_api.so").toPath(), new byte[]{2}); + in.sdkDir = new File(tmp, "sdk"); + in.branch = "beta"; + File zip = SteamDepotPackager.packageDepot(in, m -> { }); + + File content = new File(in.resultDir, "steam/content"); + assertTrue(new File(content, "MyGame").canExecute()); + assertTrue(new File(content, "launch.sh").canExecute()); + assertTrue(new File(content, "libsteam_api.so").isFile()); + assertTrue(new File(content, "libcn1ble.so").isFile()); + assertEquals("480\n", read(new File(content, "steam_appid.txt"))); + String launch = read(new File(content, "launch.sh")); + assertTrue(launch.contains("CN1_STEAM_APPID=480")); + assertTrue(launch.contains("CN1_FULLSCREEN=1")); + + String app = read(new File(in.resultDir, "steam/scripts/app_build_480.vdf")); + assertTrue(app.contains("\"SetLive\" \"beta\"")); + assertTrue(app.contains("\"481\" \"depot_build_481.vdf\"")); + assertTrue(app.contains("My \\\"Game\\\" 1.0")); + String depot = read(new File(in.resultDir, "steam/scripts/depot_build_481.vdf")); + assertTrue(depot.contains("\"FileExclusion\" \"steam_appid.txt\"")); + + Map modes = new HashMap<>(); + try (ZipFile zf = new ZipFile(zip)) { + Enumeration e = zf.getEntries(); + while (e.hasMoreElements()) { + ZipEntry ze = e.nextElement(); + modes.put(ze.getName(), ze.getUnixMode()); + } + } + assertEquals(0100755, (int) modes.get("steam/content/MyGame")); + assertEquals(0100755, (int) modes.get("steam/content/launch.sh")); + assertEquals(0100644, (int) modes.get("steam/content/steam_appid.txt")); + } + + @Test + void defaultBranchIsNeverSetLive() throws Exception { + SteamDepotPackager.Input in = input("480"); + in.depotId = "999"; + SteamDepotPackager.packageDepot(in, m -> { }); + String app = read(new File(in.resultDir, "steam/scripts/app_build_480.vdf")); + assertFalse(app.contains("SetLive")); + assertNotNull(new File(in.resultDir, "steam/scripts/depot_build_999.vdf")); + assertFalse(new File(in.resultDir, "steam/content/libsteam_api.so").exists()); + } + + @Test + void refusesMissingAppId() throws Exception { + assertThrows(BuildException.class, () -> SteamDepotPackager.packageDepot(input(null), m -> { })); + assertThrows(BuildException.class, () -> SteamDepotPackager.packageDepot(input("abc"), m -> { })); + } + + private static String read(File f) throws Exception { + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java index fcd6b4bc0f3..26a2f660d43 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java @@ -266,6 +266,63 @@ static void register(List h) { + "bar above the app, the way the native desktop ports use the window title and " + "the system menu bar.")); + h.add(new Hint("steam.appId") + .group(HintGroup.STEAM) + .type(HintType.STRING) + .valuePattern("[0-9]+") + .platform("linux") + .doc("The Steamworks app id. Required by the `steam` build target: it goes into the " + + "generated SteamPipe build scripts and into `launch.sh`, which hands it to the " + + "application so Steamworks connects even when the game is started outside Steam.")); + + h.add(new Hint("steam.depotId") + .group(HintGroup.STEAM) + .type(HintType.STRING) + .valuePattern("[0-9]+") + .platform("linux") + .doc("The Linux depot to upload the build into. Defaults to `steam.appId` + 1, which is " + + "the depot Steamworks creates with a new app.")); + + h.add(new Hint("steam.branch") + .group(HintGroup.STEAM) + .type(HintType.STRING) + .platform("linux") + .doc("A beta branch to set the uploaded build live on, such as `beta`. Empty (default) " + + "uploads without setting it live. Valve does not allow setting the default branch " + + "live from an upload: do that in the Steamworks partner site.")); + + h.add(new Hint("steam.buildDescription") + .group(HintGroup.STEAM) + .type(HintType.STRING) + .platform("linux") + .doc("The description shown for the build in the Steamworks build list. Defaults to the " + + "application version.")); + + h.add(new Hint("steam.sdkPath") + .group(HintGroup.STEAM) + .type(HintType.STRING) + .platform("linux") + .doc("Local builds only: the unzipped Steamworks SDK, whose redistributable " + + "`libsteam_api.so` is packaged with the game. Also read from the `STEAMWORKS_SDK` " + + "environment variable; the cloud build supplies it itself. Without it the game is " + + "packaged without Steamworks, and `GameServices` reports unavailable.")); + + h.add(new Hint("steam.package") + .group(HintGroup.STEAM) + .type(HintType.BOOLEAN) + .def("false") + .platform("linux") + .doc("true/false (defaults to `false`). Lays a native Linux build out as a Steam depot " + + "with its SteamPipe scripts. `cn1:buildSteam` and `cn1:buildSteamLocal` set it.")); + + h.add(new Hint("steam.fullscreen") + .group(HintGroup.STEAM) + .type(HintType.BOOLEAN) + .def("true") + .platform("linux") + .doc("true/false (defaults to `true`). Starts the game fullscreen, as Steam Deck and " + + "Steam Machine players expect.")); + h.add(new Hint("linux.arch") .group(HintGroup.LINUX) .type(HintType.STRING) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/HintGroup.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/HintGroup.java index 77259275601..738486ca136 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/HintGroup.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/HintGroup.java @@ -58,6 +58,7 @@ public enum HintGroup { MAC_NATIVE(null, "macNative."), WINDOWS(null, "windows."), LINUX(null, "linux."), + STEAM(null, "steam."), JAVASCRIPT(null, "javascript."), TV_NATIVE(null, "tvNative."), WATCH_NATIVE(null, "watchNative."), diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java index cf4e0c58f78..8fc3dfcfb3f 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java @@ -81,6 +81,7 @@ final class AppSupport { /// The local native Linux build, which `cn1RunOnDevice` deploys. static final String LINUX_LOCAL_BUILD_TASK = "buildLinuxLocal"; + static final String STEAM_LOCAL_BUILD_TASK = "buildSteamLocal"; /// The embedded board of a local Linux build (`-Pcn1.board=rpi5`). static final String BOARD_PROPERTY = "cn1.board"; @@ -541,6 +542,14 @@ static void apply(final Project project, final ProjectLayout layout, final Coden registerBuild(project, "cn1Build", null, null, "Runs the build given by -Pcodename1.platform and -Pcodename1.buildTarget", layout, main, javase, framework, ext, userProperties, queue); + // The cloud Steam build: linux-device with the steam.package hint, as cn1:buildSteam sends it. + registerBuild(project, "buildSteam", "linux", "linux-device", + "Sends a Steam depot build to the build server", + layout, main, javase, framework, ext, userProperties.map(given -> { + Map out = new java.util.LinkedHashMap(given); + out.put("codename1.arg.steam.package", "true"); + return out; + }), queue); final File settingsFile = layout.settingsFile(); final Provider> hosted = userProperties.map( @@ -557,6 +566,10 @@ static void apply(final Project project, final ProjectLayout layout, final Coden registerBuild(project, LINUX_LOCAL_BUILD_TASK, "linux", "local-linux-device", "Builds the native Linux executable locally (-Pcn1.board=rpi5 for an embedded board)", layout, main, javase, framework, ext, userProperties.zip(board, (given, b) -> boardProperties(given, b, settingsFile)), queue); + // The same build laid out as a Steam depot with its SteamPipe scripts. + registerBuild(project, STEAM_LOCAL_BUILD_TASK, "linux", "local-steam", + "Builds the native Linux executable locally as a Steam depot (needs steam.appId)", + layout, main, javase, framework, ext, userProperties, queue); // The bundle is found by looking in the build directory, so one an // earlier build left there must not be this build's answer. final Provider bundle = project.getLayout().getBuildDirectory() diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SteamUploadMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SteamUploadMojo.java new file mode 100644 index 00000000000..5cd62b0905b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SteamUploadMojo.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** + * Uploads the depot {@code cn1:buildSteamLocal} produced, with a SteamCMD you + * installed: {@code mvn cn1:steamUpload -Dsteam.user=builder}. It runs + * {@code steamcmd +login +run_app_build +quit}, so the first + * run asks for the password and Steam Guard code on this terminal and later runs + * reuse SteamCMD's cached login. + * + *

A branch named by {@code steam.branch} is set live; the default branch never + * is, because Valve allows that only from the Steamworks site. + */ +@Mojo(name = "steamUpload", requiresProject = true) +public class SteamUploadMojo extends AbstractMojo { + /** The Steam account with build upload rights for the app. */ + @Parameter(property = "steam.user", required = true) + private String user; + + /** SteamCMD; {@code steamcmd} on the PATH by default. */ + @Parameter(property = "steam.cmd", defaultValue = "steamcmd") + private String steamCmd; + + /** The depot tree: {@code target/steam} of the linux module. */ + @Parameter(property = "steam.depotDir") + private File depotDir; + + @Parameter(defaultValue = "${project.build.directory}", readonly = true) + private File buildDirectory; + + @Parameter(defaultValue = "${project.basedir}", readonly = true) + private File baseDir; + + @Override + public void execute() throws MojoExecutionException { + File steam = findDepot(); + if (steam == null) { + throw new MojoExecutionException("No Steam depot found; run mvn cn1:buildSteamLocal first " + + "(or point -Dsteam.depotDir at its steam directory)"); + } + File script = appBuildScript(new File(steam, "scripts")); + if (script == null) { + throw new MojoExecutionException("No app_build_*.vdf in " + new File(steam, "scripts")); + } + List cmd = new ArrayList<>(); + // SteamCMD runs from the scripts directory, so a relative path to it is + // made absolute against the project; a bare name is looked up on PATH. + File cmdFile = new File(steamCmd); + if (cmdFile.getParent() != null && !cmdFile.isAbsolute()) { + cmdFile = new File(baseDir, steamCmd); + } + cmd.add(cmdFile.getParent() != null ? cmdFile.getAbsolutePath() : steamCmd); + cmd.add("+login"); + cmd.add(user); + cmd.add("+run_app_build"); + // SteamCMD splits its own arguments on spaces, so an absolute path under + // "My Projects" would break; run from the scripts directory instead. + cmd.add(script.getName()); + cmd.add("+quit"); + getLog().info("Uploading " + script.getName() + " with " + steamCmd + " as " + user); + int exit; + try { + exit = new ProcessBuilder(cmd).directory(script.getParentFile()).inheritIO().start().waitFor(); + } catch (IOException ex) { + throw new MojoExecutionException("Could not run " + steamCmd + + "; install SteamCMD or set -Dsteam.cmd to its path", ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted", ex); + } + if (exit != 0) { + throw new MojoExecutionException("SteamCMD failed (exit " + exit + "); its log is in " + + new File(steam, "output")); + } + getLog().info("Uploaded. Set the default branch live, and submit for review, on the Steamworks site."); + } + + private File findDepot() { + if (depotDir != null) { + return depotDir.isDirectory() ? depotDir : null; + } + File[] candidates = { + new File(buildDirectory, "steam"), + new File(baseDir, "linux/target/steam"), + new File(baseDir, "../linux/target/steam"), + }; + for (File f : candidates) { + if (new File(f, "scripts").isDirectory()) { + return f; + } + } + return null; + } + + static File appBuildScript(File scripts) { + File[] files = scripts.listFiles((d, n) -> n.startsWith("app_build_") && n.endsWith(".vdf")); + return files == null || files.length == 0 ? null : files[0]; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/AbstractBuildWrapperMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/AbstractBuildWrapperMojo.java index 9a9bd9d4553..9c5d563b814 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/AbstractBuildWrapperMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/AbstractBuildWrapperMojo.java @@ -82,6 +82,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { properties.setProperty(key, System.getProperty(key)); } } + addProperties(properties); request.setProperties(properties); if (buildExecutableJar()) { @@ -101,6 +102,10 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } + /// Adds what this goal sends to the child build beyond the platform and target. + protected void addProperties(Properties properties) { + } + /// Runs in this Maven run once the child build has succeeded. protected void afterBuild() throws MojoExecutionException { } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamLocalMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamLocalMojo.java new file mode 100644 index 00000000000..3c7b3a9f2f9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamLocalMojo.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.buildWrappers; + +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; + +/** + * Builds the native Linux executable as a Steam depot on this machine: + * {@code mvn cn1:buildSteamLocal}. Needs the {@code steam.appId} build hint; the + * Steamworks SDK comes from {@code steam.sdkPath} or {@code STEAMWORKS_SDK}. The depot + * and its SteamPipe scripts are left in {@code linux/target/steam}, where + * {@code cn1:steamUpload} finds them. + */ +@Mojo(name = "buildSteamLocal", requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +public class BuildSteamLocalMojo extends AbstractBuildWrapperMojo { + @Override + protected String getPlatform() { + return "linux"; + } + + @Override + protected String getBuildTarget() { + return "local-steam"; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamMojo.java new file mode 100644 index 00000000000..4c3b689325b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildSteamMojo.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.buildWrappers; + +import java.util.Properties; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; + +/** + * Sends the native Linux build to the cloud as a Steam depot: {@code mvn cn1:buildSteam}. + * It is the {@code linux-device} build with the {@code steam.package} hint on. The build server supplies + * the Steamworks SDK, and with a Steam submission configured uploads the depot to + * the app named by {@code steam.appId}. + */ +@Mojo(name = "buildSteam", requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +public class BuildSteamMojo extends AbstractBuildWrapperMojo { + @Override + protected String getPlatform() { + return "linux"; + } + + @Override + protected String getBuildTarget() { + return "linux-device"; + } + + @Override + protected void addProperties(Properties properties) { + properties.setProperty("codename1.arg.steam.package", "true"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java b/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java new file mode 100644 index 00000000000..e636d47679b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java @@ -0,0 +1,123 @@ +package com.codename1.gaming; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// {@link GameServices} answers defaults with no backend or a disconnected one, +/// and forwards to a connected backend. +class GameServicesTest { + + private static class FakeBackend extends GameServicesBackend { + boolean available = true; + final Map state = new HashMap(); + int commits; + + public String getName() { + return "fake"; + } + + public boolean isAvailable() { + return available; + } + + public boolean setAchievement(String id, boolean unlocked) { + state.put("ach:" + id, Boolean.valueOf(unlocked)); + return true; + } + + public boolean isAchievementUnlocked(String id) { + return Boolean.TRUE.equals(state.get("ach:" + id)); + } + + public int getStatInt(String id, int def) { + Object v = state.get("stat:" + id); + return v instanceof Integer ? ((Integer) v).intValue() : def; + } + + public boolean setStatInt(String id, int value) { + state.put("stat:" + id, Integer.valueOf(value)); + return true; + } + + public boolean commit() { + commits++; + return true; + } + + public boolean writeSave(String name, byte[] data) { + state.put("save:" + name, data); + return true; + } + + public byte[] readSave(String name) { + return (byte[]) state.get("save:" + name); + } + } + + @AfterEach + void clear() { + GameServices.setBackend(null); + } + + @Test + void noBackendAnswersDefaults() { + assertFalse(GameServices.isAvailable()); + assertEquals("", GameServices.getPlatformName()); + assertFalse(GameServices.unlockAchievement("A")); + assertEquals(7, GameServices.getStatInt("x", 7)); + assertNull(GameServices.readSave("s")); + assertFalse(GameServices.commit()); + assertFalse(GameServices.isControllerFirst()); + } + + @Test + void disconnectedBackendIsNotCalled() { + FakeBackend b = new FakeBackend(); + b.available = false; + GameServices.setBackend(b); + assertFalse(GameServices.unlockAchievement("A")); + assertTrue(b.state.isEmpty()); + assertEquals("fake", GameServices.getPlatformName()); + } + + @Test + void connectedBackendForwards() { + FakeBackend b = new FakeBackend(); + GameServices.setBackend(b); + assertTrue(GameServices.isAvailable()); + assertTrue(GameServices.unlockAchievement("WIN")); + assertTrue(GameServices.isAchievementUnlocked("WIN")); + assertTrue(GameServices.resetAchievement("WIN")); + assertFalse(GameServices.isAchievementUnlocked("WIN")); + GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); + assertEquals(1, GameServices.getStatInt("wins", 0)); + assertTrue(GameServices.commit()); + assertEquals(1, b.commits); + byte[] data = {1, 2, 3}; + assertTrue(GameServices.writeSave("slot1", data)); + assertArrayEquals(data, GameServices.readSave("slot1")); + assertFalse(GameServices.writeSave(null, data), "null names never reach the backend"); + } + + @Test + void listenersHearThePlatformUI() { + final boolean[] got = new boolean[2]; + GameServicesListener l = new GameServicesListener() { + public void platformUIChanged(boolean shown) { + got[shown ? 0 : 1] = true; + } + }; + GameServices.addListener(l); + try { + GameServices.firePlatformUIChanged(true); + GameServices.firePlatformUIChanged(false); + assertTrue(got[0] && got[1]); + } finally { + GameServices.removeListener(l); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java b/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java new file mode 100644 index 00000000000..fd1f3081954 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java @@ -0,0 +1,195 @@ +package com.codename1.gaming; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Unit tests for {@link Gamepads}, {@link Gamepad} and the controller half of +/// {@link GameInput}: slots, dead zone, trigger buttons, listener consumption and +/// focus navigation from the D-pad and the left stick. +class GamepadsTest extends UITestBase { + + @AfterEach + void disconnectAll() { + for (int i = 0; i < 8; i++) { + Gamepads.reportDisconnected(i); + } + Gamepads.setFocusNavigation(true); + } + + private static class KeyForm extends Form { + final List pressed = new ArrayList(); + final List released = new ArrayList(); + + @Override + public void keyPressed(int keyCode) { + pressed.add(keyCode); + } + + @Override + public void keyReleased(int keyCode) { + released.add(keyCode); + } + } + + private KeyForm showKeyForm() { + KeyForm f = new KeyForm(); + f.show(); + flushSerialCalls(); + return f; + } + + @Test + void connectAndDisconnectFillSlots() { + assertFalse(Gamepads.isAnyConnected()); + Gamepad a = Gamepads.reportConnected(0, "Pad A"); + Gamepads.reportConnected(2, "Pad C"); + assertTrue(Gamepads.isAnyConnected()); + assertEquals(2, Gamepads.getGamepads().length); + assertSame(a, Gamepads.getGamepad(0)); + assertNull(Gamepads.getGamepad(1)); + assertEquals("Pad A", a.getName()); + assertSame(a, Gamepads.reportConnected(0, "again"), "a second connect of a slot is the same pad"); + Gamepads.reportDisconnected(0); + assertFalse(a.isConnected()); + assertNull(Gamepads.getGamepad(0)); + assertEquals(1, Gamepads.getGamepads().length); + } + + @Test + void deadZoneZeroesAndRescales() { + assertEquals(0f, Gamepad.applyDeadZone(0.1f, 0.15f), 0.0001); + assertEquals(0f, Gamepad.applyDeadZone(-0.15f, 0.15f), 0.0001); + assertEquals(1f, Gamepad.applyDeadZone(1f, 0.15f), 0.0001); + assertEquals(-1f, Gamepad.applyDeadZone(-1f, 0.15f), 0.0001); + assertEquals(0.5f, Gamepad.applyDeadZone(0.575f, 0.15f), 0.0001); + + Gamepad p = Gamepads.reportConnected(0, "p"); + Gamepads.reportAxis(0, Gamepad.AXIS_RIGHT_X, 0.1f); + assertEquals(0f, p.getAxis(Gamepad.AXIS_RIGHT_X), 0.0001); + assertEquals(0.1f, p.getRawAxis(Gamepad.AXIS_RIGHT_X), 0.0001); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_TRIGGER, 0.1f); + assertEquals(0.1f, p.getAxis(Gamepad.AXIS_LEFT_TRIGGER), 0.0001, "triggers have no dead zone"); + Gamepads.reportAxis(0, Gamepad.AXIS_RIGHT_X, 7f); + assertEquals(1f, p.getRawAxis(Gamepad.AXIS_RIGHT_X), 0.0001, "values are clamped"); + } + + @Test + void triggerPastHalfwayIsAButton() { + Gamepad p = Gamepads.reportConnected(0, "p"); + Gamepads.reportAxis(0, Gamepad.AXIS_RIGHT_TRIGGER, 0.4f); + assertFalse(p.isButtonDown(Gamepad.BUTTON_RIGHT_TRIGGER)); + Gamepads.reportAxis(0, Gamepad.AXIS_RIGHT_TRIGGER, 0.8f); + assertTrue(p.isButtonDown(Gamepad.BUTTON_RIGHT_TRIGGER)); + Gamepads.reportAxis(0, Gamepad.AXIS_RIGHT_TRIGGER, 0f); + assertFalse(p.isButtonDown(Gamepad.BUTTON_RIGHT_TRIGGER)); + } + + @Test + void listenerSeesButtonsAndCanConsume() { + KeyForm f = showKeyForm(); + final int[] seen = new int[1]; + GamepadListener l = new GamepadListener() { + public void gamepadConnected(Gamepad pad) { + } + + public void gamepadDisconnected(Gamepad pad) { + } + + public boolean gamepadButton(Gamepad pad, int button, boolean down) { + seen[0]++; + return true; + } + }; + Gamepads.addListener(l); + try { + Gamepads.reportConnected(0, "p"); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_DOWN, true); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_DOWN, true); + flushSerialCalls(); + assertEquals(1, seen[0], "a repeated report of the same state is not a change"); + assertTrue(f.pressed.isEmpty(), "a consumed button does not navigate"); + } finally { + Gamepads.removeListener(l); + } + } + + @Test + void dpadAndAButtonNavigate() { + KeyForm f = showKeyForm(); + Gamepads.reportConnected(0, "p"); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_DOWN, true); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_DOWN, false); + Gamepads.reportButton(0, Gamepad.BUTTON_A, true); + Gamepads.reportButton(0, Gamepad.BUTTON_A, false); + flushSerialCalls(); + Display d = Display.getInstance(); + assertEquals(2, f.pressed.size()); + assertEquals(d.getKeyCode(Display.GAME_DOWN), f.pressed.get(0).intValue()); + assertEquals(d.getKeyCode(Display.GAME_FIRE), f.pressed.get(1).intValue()); + assertEquals(2, f.released.size()); + } + + @Test + void stickNavigatesWithHysteresis() { + KeyForm f = showKeyForm(); + Gamepads.reportConnected(0, "p"); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_X, 0.5f); + flushSerialCalls(); + assertTrue(f.pressed.isEmpty(), "under the press threshold"); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_X, 0.7f); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_X, 0.5f); + flushSerialCalls(); + assertEquals(1, f.pressed.size(), "between the thresholds the direction holds"); + assertTrue(f.released.isEmpty()); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_X, 0.1f); + flushSerialCalls(); + assertEquals(1, f.released.size()); + assertEquals(Display.getInstance().getKeyCode(Display.GAME_RIGHT), f.released.get(0).intValue()); + } + + @Test + void navigationCanBeTurnedOff() { + KeyForm f = showKeyForm(); + Gamepads.setFocusNavigation(false); + Gamepads.reportConnected(0, "p"); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_UP, true); + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_Y, -1f); + flushSerialCalls(); + assertTrue(f.pressed.isEmpty()); + } + + @Test + void disconnectReleasesHeldButtons() { + KeyForm f = showKeyForm(); + Gamepads.reportConnected(0, "p"); + Gamepads.reportButton(0, Gamepad.BUTTON_DPAD_LEFT, true); + Gamepads.reportDisconnected(0); + flushSerialCalls(); + assertEquals(1, f.released.size(), "a pad unplugged mid-press must not leave the key down"); + } + + @Test + void gameInputReadsControllers() { + GameInput in = new GameInput(); + Gamepads.reportConnected(0, "p"); + Gamepads.reportButton(0, Gamepad.BUTTON_X, true, false); + assertTrue(in.isGamepadButtonDown(Gamepad.BUTTON_X)); + assertTrue(in.wasGamepadButtonPressed(Gamepad.BUTTON_X)); + in.clearFrameEdges(); + assertFalse(in.wasGamepadButtonPressed(Gamepad.BUTTON_X)); + Gamepads.reportButton(0, Gamepad.BUTTON_X, false, false); + assertTrue(in.wasGamepadButtonReleased(Gamepad.BUTTON_X)); + + Gamepads.reportAxis(0, Gamepad.AXIS_LEFT_X, 1f); + assertEquals(1f, in.getAxisX(), 0.0001, "the left stick stands in for the joystick"); + in.setAxis(-0.5f, 0f); + assertEquals(-0.5f, in.getAxisX(), 0.0001, "a held on-screen joystick wins"); + } +} diff --git a/scripts/ci/steam-runtime-smoke.sh b/scripts/ci/steam-runtime-smoke.sh new file mode 100755 index 00000000000..65d34092441 --- /dev/null +++ b/scripts/ci/steam-runtime-smoke.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs a native Linux executable inside the Steam Linux Runtime 3 ("sniper") +# platform image, the container Steam launches Linux games in, and fails when +# the binary needs a library the runtime lacks or dies on start-up. +# +# scripts/ci/steam-runtime-smoke.sh [seconds] +# +# It needs docker and an X server: the host's Xvfb socket is shared with the +# container. The binary passes when it is still running after (default +# 15), or exits 0 before then. +set -euo pipefail + +ELF="${1:?usage: steam-runtime-smoke.sh [seconds]}" +SECS="${2:-15}" +# Deliberately the moving tag: Steam ships the current runtime to players, so a +# new one that breaks the binary is a failure worth seeing on the next PR. +IMAGE="${CN1_STEAM_RUNTIME_IMAGE:-registry.gitlab.steamos.cloud/steamrt/sniper/platform:latest}" +DISPLAY_NUM="${CN1_STEAM_SMOKE_DISPLAY:-:97}" + +if [ ! -f "$ELF" ]; then + echo "steam-runtime-smoke: $ELF does not exist" >&2 + exit 1 +fi +DIR="$(cd "$(dirname "$ELF")" && pwd)" +NAME="$(basename "$ELF")" + +Xvfb "$DISPLAY_NUM" -ac -screen 0 1280x800x24 >/dev/null 2>&1 & +XPID=$! +trap 'kill $XPID 2>/dev/null || true' EXIT +sleep 2 + +docker pull -q "$IMAGE" >/dev/null + +# 1. Every DT_NEEDED resolves inside the runtime. +MISSING="$(docker run --rm -v "$DIR:/game:ro" "$IMAGE" ldd "/game/$NAME" | grep 'not found' || true)" +if [ -n "$MISSING" ]; then + echo "steam-runtime-smoke: libraries missing from the Steam Linux Runtime:" >&2 + echo "$MISSING" >&2 + exit 1 +fi + +# 2. It starts and keeps running, full screen as under Gaming Mode. +rc=0 +# --ipc=host: GTK and cairo attach MIT-SHM segments the X server must see. +docker run --rm --ipc=host -v "$DIR:/game:ro" -v /tmp/.X11-unix:/tmp/.X11-unix \ + -e DISPLAY="$DISPLAY_NUM" -e LIBGL_ALWAYS_SOFTWARE=1 -e SteamGamepadUI=1 \ + "$IMAGE" timeout "$SECS" "/game/$NAME" || rc=$? +if [ "$rc" -eq 124 ] || [ "$rc" -eq 0 ]; then + echo "steam-runtime-smoke: $NAME ran in the Steam Linux Runtime (${SECS}s)" + exit 0 +fi +echo "steam-runtime-smoke: $NAME exited with $rc inside the Steam Linux Runtime" >&2 +exit 1 diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index 1d97f5568bf..773c394cd3b 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -1787,7 +1787,14 @@ private static void writeLinuxLinkSet(Writer writer) throws IOException { // build distro's CURL_OPENSSL_4 tags, absent on Alma/RHEL's libcurl. // Keep its headers/compiler flags, but do not link CN1CURL_LIBRARIES. writer.append("pkg_check_modules(CN1CURL REQUIRED libcurl)\n"); - writer.append("pkg_check_modules(CN1GL REQUIRED epoxy egl glesv2)\n"); + // GL goes through libepoxy, which dlopen()s libEGL and libGLESv2 itself at + // first use, so only epoxy is linked; egl/glesv2 are probed for their + // headers alone. Linking them made each a DT_NEEDED, and the Steam Linux + // Runtime (sniper) ships libEGL but no libGLESv2 -- the binary would not + // start there at all. On a system without GLES the GL surface reports + // unsupported instead and Cairo draws everything. + writer.append("pkg_check_modules(CN1GL REQUIRED epoxy)\n"); + writer.append("pkg_check_modules(CN1GLHDR REQUIRED egl glesv2)\n"); // Optional feature libs (browser/media/secure-storage/notifications/ // location). The port dlopen()s these lazily at first use (see // cn1_linux_browser.c / cn1_linux_media.c / cn1_linux_services.c), so we @@ -1798,7 +1805,7 @@ private static void writeLinuxLinkSet(Writer writer) throws IOException { writer.append("pkg_check_modules(CN1OPT REQUIRED\n"); writer.append(" gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0\n"); writer.append(" webkit2gtk-4.1 libsecret-1 libnotify libgeoclue-2.0)\n"); - writer.append("target_include_directories(${PROJECT_NAME} PRIVATE ${CN1DEPS_INCLUDE_DIRS} ${CN1CRYPTO_INCLUDE_DIRS} ${CN1CURL_INCLUDE_DIRS} ${CN1GL_INCLUDE_DIRS} ${CN1OPT_INCLUDE_DIRS})\n"); + writer.append("target_include_directories(${PROJECT_NAME} PRIVATE ${CN1DEPS_INCLUDE_DIRS} ${CN1CRYPTO_INCLUDE_DIRS} ${CN1CURL_INCLUDE_DIRS} ${CN1GL_INCLUDE_DIRS} ${CN1GLHDR_INCLUDE_DIRS} ${CN1OPT_INCLUDE_DIRS})\n"); writer.append("target_compile_options(${PROJECT_NAME} PRIVATE ${CN1DEPS_CFLAGS_OTHER} ${CN1CURL_CFLAGS_OTHER} ${CN1GL_CFLAGS_OTHER} ${CN1OPT_CFLAGS_OTHER})\n"); // libm/pthread back the translated runtime + GC; dl is required for the // dlopen() of the optional libs above. Note: CN1OPT_LIBRARIES is From f78305b3422ff6795593f6b2531ea9781572179a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:52:58 +0200 Subject: [PATCH 2/6] Linux port: end the edit without Display.isTextEditing, which is not public Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/impl/linux/LinuxGameServices.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java index 3544d46e0ae..9d70f7bcb96 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGameServices.java @@ -93,7 +93,8 @@ public void run() { public void run() { Component c = keyboardTarget; keyboardTarget = null; - if (c != null && Display.getInstance().isTextEditing(c)) { + // stopEditing is a no-op unless c is still editing. + if (c != null) { Display.getInstance().stopEditing(c); } } From 9efebfe04e2295210d986de7420d312e57a72b97 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:42:09 +0200 Subject: [PATCH 3/6] Steam guide: snippets from docs/demos; copyright headers on the gaming tests Co-Authored-By: Claude Opus 5.5 --- .../snippets/SteamSnippets.java | 61 +++++++++++++++++++ .../snippets/developer-guide/steam.properties | 6 ++ .../main/snippets/developer-guide/steam.sh | 9 +++ docs/developer-guide/Steam.asciidoc | 22 ++----- .../codename1/gaming/GameServicesTest.java | 22 +++++++ .../com/codename1/gaming/GamepadsTest.java | 22 +++++++ 6 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java create mode 100644 docs/demos/common/src/main/snippets/developer-guide/steam.properties create mode 100644 docs/demos/common/src/main/snippets/developer-guide/steam.sh diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java new file mode 100644 index 00000000000..30cac9711b7 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.gaming.GameServices; +import com.codename1.gaming.Gamepad; +import com.codename1.gaming.Gamepads; + +/** Compiled source snippets for the Steam guide chapter. */ +public class SteamSnippets { + private float stickX; + + /** Reads the first controller once per frame. */ + public void readPad() { + // tag::steam-java-001[] + Gamepad pad = Gamepads.getGamepad(0); + if (pad != null && pad.isConnected()) { + float x = pad.getAxis(Gamepad.AXIS_LEFT_X); // -1..1, dead zone applied + if (pad.isButtonDown(Gamepad.BUTTON_A)) { + jump(); + } + } + // end::steam-java-001[] + } + + /** Achievements, stats and a cloud save after a won match. */ + public void recordWin(byte[] saveBytes) { + // tag::steam-java-002[] + if (GameServices.isAvailable()) { // true on Steam + GameServices.unlockAchievement("FIRST_WIN"); + GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); + GameServices.commit(); + GameServices.writeSave("slot1", saveBytes); + } + // end::steam-java-002[] + } + + private void jump() { + stickX = 0; + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/steam.properties b/docs/demos/common/src/main/snippets/developer-guide/steam.properties new file mode 100644 index 00000000000..7908a4dfdac --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/steam.properties @@ -0,0 +1,6 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::steam-properties-001[] +codename1.arg.steam.appId=480 +codename1.arg.steam.sdkPath=/path/to/steamworks_sdk +// end::steam-properties-001[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/steam.sh b/docs/demos/common/src/main/snippets/developer-guide/steam.sh new file mode 100644 index 00000000000..ca9fe94b493 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/steam.sh @@ -0,0 +1,9 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::steam-bash-001[] +mvn cn1:buildSteamLocal # or ./gradlew buildSteamLocal +// end::steam-bash-001[] + +// tag::steam-bash-002[] +mvn cn1:steamUpload -Dsteam.user=my_build_account +// end::steam-bash-002[] diff --git a/docs/developer-guide/Steam.asciidoc b/docs/developer-guide/Steam.asciidoc index 385ebd1bb76..06d84db8db6 100644 --- a/docs/developer-guide/Steam.asciidoc +++ b/docs/developer-guide/Steam.asciidoc @@ -27,13 +27,7 @@ and the menu buttons. [source,java] ---- -Gamepad pad = Gamepads.getGamepad(0); -if (pad != null && pad.isConnected()) { - float x = pad.getAxis(Gamepad.AXIS_LEFT_X); // -1..1, dead zone applied - if (pad.isButtonDown(Gamepad.BUTTON_A)) { - jump(); - } -} +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java[tag=steam-java-001,indent=0] ---- `GameInput` reads the first controller too: `wasGamepadButtonPressed` gives the @@ -59,12 +53,7 @@ pad, and the port skips the devices Steam hides so a controller never appears tw [source,java] ---- -if (GameServices.isAvailable()) { // true on Steam - GameServices.unlockAchievement("FIRST_WIN"); - GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); - GameServices.commit(); - GameServices.writeSave("slot1", saveBytes); -} +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java[tag=steam-java-002,indent=0] ---- * *Achievements and stats* use the API names defined on the Steamworks site. @@ -100,15 +89,14 @@ Add the hints to `codenameone_settings.properties`: [source,properties] ---- -codename1.arg.steam.appId=480 -codename1.arg.steam.sdkPath=/path/to/steamworks_sdk +include::../demos/common/src/main/snippets/developer-guide/steam.properties[tag=steam-properties-001,indent=0] ---- and build: [source,bash] ---- -mvn cn1:buildSteamLocal # or ./gradlew buildSteamLocal +include::../demos/common/src/main/snippets/developer-guide/steam.sh[tag=steam-bash-001,indent=0] ---- The SDK path can also come from the `STEAMWORKS_SDK` environment variable. Without @@ -148,7 +136,7 @@ With SteamCMD installed: [source,bash] ---- -mvn cn1:steamUpload -Dsteam.user=my_build_account +include::../demos/common/src/main/snippets/developer-guide/steam.sh[tag=steam-bash-002,indent=0] ---- The first run asks for the password and the Steam Guard code; later runs reuse diff --git a/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java b/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java index e636d47679b..a2346e01656 100644 --- a/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/gaming/GameServicesTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.gaming; import java.util.HashMap; diff --git a/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java b/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java index fd1f3081954..1b3b8b272c3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/gaming/GamepadsTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.gaming; import com.codename1.junit.UITestBase; From f2c19d6eb06e001ac55aa0df9dd83088915857e4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:53:48 +0200 Subject: [PATCH 4/6] Steam guide: snippets compile on their own in the Playground Co-Authored-By: Claude Opus 5.5 --- .../developerguide/snippets/SteamSnippets.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java index 30cac9711b7..8f01b958cac 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/SteamSnippets.java @@ -28,24 +28,22 @@ /** Compiled source snippets for the Steam guide chapter. */ public class SteamSnippets { - private float stickX; - /** Reads the first controller once per frame. */ public void readPad() { // tag::steam-java-001[] Gamepad pad = Gamepads.getGamepad(0); if (pad != null && pad.isConnected()) { float x = pad.getAxis(Gamepad.AXIS_LEFT_X); // -1..1, dead zone applied - if (pad.isButtonDown(Gamepad.BUTTON_A)) { - jump(); - } + boolean jump = pad.isButtonDown(Gamepad.BUTTON_A); + System.out.println("stick " + x + ", jump " + jump); } // end::steam-java-001[] } /** Achievements, stats and a cloud save after a won match. */ - public void recordWin(byte[] saveBytes) { + public void recordWin() { // tag::steam-java-002[] + byte[] saveBytes = "level=3".getBytes(); if (GameServices.isAvailable()) { // true on Steam GameServices.unlockAchievement("FIRST_WIN"); GameServices.setStat("wins", GameServices.getStatInt("wins", 0) + 1); @@ -54,8 +52,4 @@ public void recordWin(byte[] saveBytes) { } // end::steam-java-002[] } - - private void jump() { - stickX = 0; - } } From 8bbac3d08775cf23cfae8199db66704db4da2af3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:56:00 +0200 Subject: [PATCH 5/6] Linux gamepad: parse the ignore list with strtoul, not sscanf glibc 2.38 headers rename sscanf to __isoc23_sscanf, which the glibc 2.28 link target lacks; the arm64 suite failed to link. Also a contraction in the steam.branch hint and gamepad in the guide's accepted words. Co-Authored-By: Claude Opus 5.5 --- Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c | 14 +++++++++++--- docs/developer-guide/languagetool-accept.txt | 1 + .../codename1/build/shared/BuildHintsDesktop.java | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c b/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c index afd5da2bc55..0343cacb408 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gamepad.c @@ -104,9 +104,17 @@ static int cn1PadIgnored(unsigned vid, unsigned pid) { return 0; } while (*p) { - unsigned v = 0, d = 0; - if (sscanf(p, "%x/%x", &v, &d) == 2 && v == vid && d == pid) { - return 1; + /* strtoul, not sscanf: glibc 2.38 headers rename sscanf to + __isoc23_sscanf, which the glibc 2.28 link target lacks, while + cn1_linux_glibc_compat.c covers the strto* family. */ + char* end = NULL; + unsigned long v = strtoul(p, &end, 16); + if (end != p && *end == '/') { + const char* q = end + 1; + unsigned long d = strtoul(q, &end, 16); + if (end != q && v == vid && d == pid) { + return 1; + } } while (*p && *p != ',') { p++; diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 99a02aa3c17..6b4dff2e4d9 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -903,3 +903,4 @@ picocli subcommand subcommands transpiles +gamepad diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java index 26a2f660d43..ce648a7a87d 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java @@ -288,7 +288,7 @@ static void register(List h) { .type(HintType.STRING) .platform("linux") .doc("A beta branch to set the uploaded build live on, such as `beta`. Empty (default) " - + "uploads without setting it live. Valve does not allow setting the default branch " + + "uploads without setting it live. Valve doesn't allow setting the default branch " + "live from an upload: do that in the Steamworks partner site.")); h.add(new Hint("steam.buildDescription") From fce734cb99d735188bbbabe492632cb5f6ddb016 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:22:29 +0200 Subject: [PATCH 6/6] Steam guide: no paragraph starts lowercase Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Steam.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/Steam.asciidoc b/docs/developer-guide/Steam.asciidoc index 06d84db8db6..8bfa4afc7f1 100644 --- a/docs/developer-guide/Steam.asciidoc +++ b/docs/developer-guide/Steam.asciidoc @@ -92,7 +92,7 @@ Add the hints to `codenameone_settings.properties`: include::../demos/common/src/main/snippets/developer-guide/steam.properties[tag=steam-properties-001,indent=0] ---- -and build: +Then build: [source,bash] ----