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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ endif()
if (ANDROID)
list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_glue.cpp)
list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_diag.cpp)
list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_touch.cpp)
endif()

target_include_directories(Goemon64Recompiled PRIVATE
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ The APK does not include the game. You'll need your own legally obtained ROM.

1. Install the APK and open the app.
2. On first launch, you'll be asked to pick your ROM file — use the file picker, it gets copied into the app's own storage.
3. Make sure you have a gamepad. **The game is controller-only** — there is no touchscreen control scheme. A handheld's built-in controls work as-is; on a phone, pair a physical or Bluetooth pad first.
3. Play with whatever you have. A handheld's built-in controls work as-is, and a physical or Bluetooth pad works on a phone. If there is no gamepad, **on-screen controls** appear automatically — see [On-Screen Controls](#on-screen-controls) below.
4. Press Start.

**Requirements:** Android 9.0+, a 64-bit (`arm64-v8a`) device, and a Vulkan-capable GPU. This covers effectively any phone or handheld from the last several years. Tested primarily on Snapdragon/Adreno handhelds (Retroid Pocket 5, AYN Thor).
**Requirements:** Android 9.0+, a 64-bit (`arm64-v8a`) device, and a Vulkan-capable GPU. A gamepad is recommended but no longer required. This covers effectively any phone or handheld from the last several years. Tested primarily on Snapdragon/Adreno handhelds (Retroid Pocket 5, AYN Thor).

## Something's Wrong — Quick Fixes

Expand All @@ -36,6 +36,29 @@ The APK does not include the game. You'll need your own legally obtained ROM.

More detail on each of these is in [Troubleshooting Details](#troubleshooting-details) below.

## On-Screen Controls

On a device with no gamepad, a full N64 pad is drawn over the game: analog stick under
the left thumb, A and B under the right with the C-buttons above them, L/Z/R along the
top edge, and Start in the middle.

By default it **hides as soon as a gamepad is used** and comes back the next time you
touch the screen, so a handheld with real sticks never sees it and a phone never has to
go looking for a setting.

Whether it appears at all is under **Settings → Touch → On-Screen Controls**
(Auto / On / Off), along with **Edit Layout**, which lets you drag the controls
wherever your hands actually want them, over the running game.

**Long-press the ☰ handle** for size, opacity and vibration. A short tap on ☰ opens
the game's settings menu (☰ is the on-screen stand-in for Select).

The on-screen buttons go through the same bindings as a physical controller, so
anything you remap in **Settings → Controls** moves them too, and they work alongside a
real pad rather than instead of it.

Full detail, and how to work on the layout: [docs/touch-controls.md](docs/touch-controls.md).

## Default Controls

The default gamepad layout (Xbox-style face buttons). Everything is remappable in **Settings → Controls**.
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<!-- ROM is chosen via SAF and copied into app-scoped storage; no legacy
storage permissions needed. -->

<!-- Haptic feedback for the on-screen controls. A normal permission, granted at
install with no runtime prompt. Without it Vibrator.vibrate() throws
SecurityException, and since the buzz fires on every on-screen button press
that is an instant crash the first time the player touches A. -->
<uses-permission android:name="android.permission.VIBRATE" />

<uses-feature android:name="android.hardware.vulkan.version"
android:version="0x400003" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
Expand Down
79 changes: 79 additions & 0 deletions android/app/src/main/java/com/goemon64/recomp/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;

import com.goemon64.recomp.touch.TouchOverlayController;

import org.libsdl.app.SDLActivity;

import java.io.File;
Expand Down Expand Up @@ -56,6 +61,12 @@ public static boolean isGameRunning() {
// with sGameRunning for clarity.
private static volatile boolean sNativeInitedThisProcess = false;

/**
* The on-screen N64 controls, drawn over SDL's surface. Null only in the early
* bail-out paths of onCreate, which finish the activity before any UI exists.
*/
private TouchOverlayController touchOverlay;

// Must match goemon64::RestartTarget in include/goemon_support.h.
private static final int RESTART_NONE = 0;
private static final int RESTART_APP_MENU = 1;
Expand Down Expand Up @@ -143,6 +154,16 @@ protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

// On-screen controls, added after super.onCreate() because that is what builds
// SDL's layout. Adding to that same layout, after its SurfaceView, is what puts
// the pad on top of the picture: the surface sets no Z-order override, so it
// composites below the window's ordinary views.
View content = getContentView();
if (content instanceof ViewGroup) {
touchOverlay = new TouchOverlayController(this);
touchOverlay.attachTo((ViewGroup) content);
}

getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
getWindow().getAttributes().layoutInDisplayCutoutMode =
Expand Down Expand Up @@ -384,11 +405,55 @@ private void hideSystemUI() {
protected void onResume() {
super.onResume();
hideSystemUI();
if (touchOverlay != null) {
touchOverlay.onResume();
}
}

@Override
protected void onPause() {
// Before super, so anything held on the overlay is released while the native
// side is still listening -- otherwise a button held at the moment the app was
// backgrounded stays held when it returns.
if (touchOverlay != null) {
touchOverlay.onPause();
}
super.onPause();
}

/**
* Gamepad sniffing for the overlay's auto-hide.
*
* <p>Both dispatch hooks only look, and always delegate: SDL owns the real
* handling of these events, and the overlay's interest in them is limited to
* noticing that a physical pad exists so it can get off the screen. Doing this
* here rather than in the view is deliberate -- the view sits above SDL's surface
* and must not compete with it for key or motion events.
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (touchOverlay != null) {
touchOverlay.noteInputEvent(event);
}
return super.dispatchKeyEvent(event);
}

@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (touchOverlay != null) {
touchOverlay.noteInputEvent(event);
}
return super.dispatchGenericMotionEvent(event);
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (!hasFocus && touchOverlay != null) {
// Losing focus without pausing (a notification shade, a permission dialog)
// still means fingers have left the glass.
touchOverlay.view().release();
}
// Immersive flags set in onCreate()/onResume() run before the window
// first gains focus, so Android drops them and the status/navigation
// bars stay visible on launch. Re-apply once we actually have focus
Expand Down Expand Up @@ -488,6 +553,20 @@ private void restartInto(boolean autostart) {
startActivity(intent);
}

/**
* Put the on-screen controls into layout-edit mode, from the game's settings menu.
*
* <p>Called from native code (android_glue.cpp) on the render thread, so the work
* is posted to the UI thread by the controller. Named to match the
* {@code GetMethodID} lookup in nativeInit -- renaming this silently breaks the
* menu button, since a missing method id is tolerated rather than fatal.
*/
void requestTouchLayoutEditor() {
if (touchOverlay != null) {
touchOverlay.requestEditorFromNative();
}
}

// Implemented in android_glue.cpp.
public native void nativeInit(String dataPath, boolean autostart);
/**
Expand Down
162 changes: 162 additions & 0 deletions android/app/src/main/java/com/goemon64/recomp/touch/NativeTouch.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.goemon64.recomp.touch;

/**
* The one seam between the on-screen controls and the game.
*
* <p>A few calls, none of them blocking: the overlay pushes
* the whole virtual pad every time it changes, and native code reads it from the
* game thread. See {@code include/goemon_touch.h} for what happens on the far side.
*
* <h2>Why every call is guarded</h2>
* {@code libGoemon64.so} is loaded by {@code MainActivity}'s static initialiser, so in
* the app these calls always resolve. The view does not assume that, though: the first
* call latches whether the symbols are actually there, and every call after that is a
* field test, so hosting the overlay in a process without the library degrades to a
* pad that sends nothing rather than an UnsatisfiedLinkError. Nothing here throws.
*/
public final class NativeTouch {

private NativeTouch() {}

/**
* Tri-state: unknown until the first call, then permanently available or not.
* Never reset — a process either has the game's native library or it does not.
*/
private static Boolean available;

private static boolean available() {
if (available == null) {
try {
// A no-op probe: clearing state that is already clear is harmless, and
// it is the cheapest way to find out whether the symbols resolved.
nativeClearState();
available = Boolean.TRUE;
} catch (UnsatisfiedLinkError e) {
available = Boolean.FALSE;
}
}
return available;
}

/**
* Push the complete virtual pad state.
*
* @param buttonMask bit {@code n} set means SDL button {@code n} is held
* @param axes indexed by SDL axis; length may be short, the rest read neutral
*/
public static void setState(int buttonMask, float[] axes) {
if (!available()) {
return;
}
try {
nativeSetState(buttonMask, axes);
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
}
}

/**
* Tell native code whether the overlay is currently driving input. Passing
* {@code false} also drops the pad to neutral, so a button held at the instant
* the overlay was hidden cannot stay held.
*/
public static void setActive(boolean active) {
if (!available()) {
return;
}
try {
nativeSetActive(active);
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
}
}

/**
* Whether a native menu is capturing input, so the overlay can hide and let its
* touches through to the menu underneath. Returns false if the native library is
* not loaded.
*/
public static boolean isMenuOpen() {
if (!available()) {
return false;
}
try {
return nativeIsMenuOpen();
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
return false;
}
}

/**
* Ask the game to open (or close) its config menu, as the settings handle.
*
* <p>Not routed through the virtual pad: the menu toggle is event-driven on the
* native side, so a bit in the polled button mask is never seen by it. See
* {@code goemon64::touch::request_menu_toggle}.
*/
public static void requestMenuToggle() {
if (!available()) {
return;
}
try {
nativeRequestMenuToggle();
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
}
}

/**
* Visibility mode chosen in the game's own settings menu, or {@code null} when the
* native library is absent, in which case the locally stored layout value stands in.
*
* <p>The game config owns this rather than SharedPreferences so there is exactly
* one source of truth: the player changes it in the menu they already have open,
* and it is saved with the rest of their settings.
*/
public static TouchLayout.Visibility mode() {
if (!available()) {
return null;
}
try {
switch (nativeGetMode()) {
case 1: return TouchLayout.Visibility.ALWAYS;
case 2: return TouchLayout.Visibility.NEVER;
default: return TouchLayout.Visibility.AUTO;
}
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
return null;
}
}

/**
* Stick response from the game config, 0..100, or -1 when the native library is
* absent, in which case the pad keeps its current setting.
*/
public static int stickSensitivity() {
if (!available()) {
return -1;
}
try {
return nativeGetStickSensitivity();
} catch (UnsatisfiedLinkError ignored) {
available = Boolean.FALSE;
return -1;
}
}

private static native void nativeSetState(int buttonMask, float[] axes);

private static native int nativeGetStickSensitivity();

private static native int nativeGetMode();

private static native void nativeRequestMenuToggle();

private static native boolean nativeIsMenuOpen();

private static native void nativeSetActive(boolean active);

private static native void nativeClearState();
}
Loading