Skip to content

Add native touch controls with live layout editor (FYI / optional merge)#1

Open
mgrz18 wants to merge 77 commits intoizzy2lost:portfrom
mgrz18:touch-controls
Open

Add native touch controls with live layout editor (FYI / optional merge)#1
mgrz18 wants to merge 77 commits intoizzy2lost:portfrom
mgrz18:touch-controls

Conversation

@mgrz18
Copy link
Copy Markdown

@mgrz18 mgrz18 commented Apr 23, 2026

Hola Izzy 👋 — no es un PR urgente, es más bien un FYI por si quieres integrar / cherry-pickear / tomar ideas. No hay problema si lo cierras sin mergear.

Qué trae

Partí de tu fork (que me dejó prácticamente resuelto el andamiaje Gradle + SDL2 + LauncherActivity con SAF, gracias por eso) y añadí controles táctiles nativos al estilo CoD Mobile / Fortnite encima del overlay de SDL. El resumen:

  • Overlay táctil completo: mitad izquierda del screen = movement (floating stick que se ancla donde pones el pulgar), mitad derecha = camera (drag-to-look inyectando mouse delta en inputUpdateMouse para que el juego lo trate como mouselook y se detenga al soltar, no como stick analógico).
  • Botones flotantes para FIRE / AIM / USE / RELOAD / ALT / change weapon / radial / crouch / start / back con iconos vectoriales dibujados en Canvas.
  • Editor en vivo accesible desde un pill flotante en la esquina superior derecha: drag-to-move, per-button resize bar, sensibilidad X/Y independiente del look-pad, auto-fade toggle, todo persistido en SharedPreferences.
  • Auto-fade configurable: el overlay desaparece tras 7 s sin tocarlo y reaparece al instante en el próximo toque.
  • Auto-launch al juego cuando el ROM ya está en la carpeta de la app, saltando el launcher.
  • Cherry-picks del upstream (fgsfdsfgs/perfect_dark): fix de SIGBUS en ARM 32-bit (nivel 3), vertical camera wrap, UI Accept/Cancel swap, Camera Recenter action, separación de sensibilidad crosshair/cámara.
  • CI de Android (.github/workflows/android.yml) que compila APK en cada push.

Detalles técnicos relevantes

  • GameView.java y TouchControls.java originales los borré — quedaban desconectados porque MainActivity extends SDLActivity renderea por su cuenta, así que ese camino no se usaba.
  • El bridge nativo vive en port/src/touch.c con un seqlock simple de doble buffer para que el thread del juego nunca lea un snapshot a medio escribir.
  • TouchOverlayView es un View que se agrega a SDLActivity.mLayout como sibling de la SDL surface; los touches los consume el overlay primero y solo pasa al SDL surface cuando no hay hit.
  • El look-pad no usa rstick_x/y (que en CONTROLMODE_PC controla strafe), sino que acumula pixeles en un atomic que touchConsumeLookDelta drena una vez por frame hacia mouseDX/mouseDY. Eso da la sensación correcta: arrastras, gira; sueltas, se detiene. Es exactamente lo que la gente espera viniendo de Quake Mobile / CoD Mobile.
  • El move-pad sí feedea rstick_x/y (porque en CONTROLMODE_PC stick2 es walk/strafe).

Release + APK

Hay release público con APK debug en el fork: https://github.com/mgrz18/perfect_dark/releases/tag/touch-v0.1.0

Créditos

El README nuevo (mgrz18/perfect_dark) te lista explícitamente como autor del andamiaje inicial de Android, junto con fgsfdsfgs por el port a plataformas modernas y n64decomp por el decomp original.

Gracias por el trabajo que ya tenías — ahorró muchísimo tiempo. Si quieres integrar esto de vuelta o usarlo como referencia, eres libre de hacerlo. Si prefieres cerrarlo sin más, también está bien 🙏


Este PR fue preparado con asistencia de Claude (Anthropic) para acelerar la parte de ingeniería.

AL2009man and others added 30 commits June 27, 2025 21:18
Crosshair Edge Deadzone slider and fixed `insightaim` Camera Movement
Due to a Windows Update regression: RawInput driver has a issue related to Xbox Controllers' trigger input only being detected. For safety reasons, `useRawInput` will be disabled by default until the bug is fixed on Microsoft's end.

for those who still needs it: you can head over to `pd.ini` and revert `useRawInput` back to `1`
Temporarily disables RawInput driver by default
Fix light transparency for tinted glass doors
Exclude laser doors from lighting line-of-sight test
…csim

Limit 'Start Armed' option to Combat Simulator
Addresses the issue with Crosshair Swivel conflciting with each other when using both Mouse and Camera at the same time.
fixed Crosshair Swivel conflicting when using Mixed Input
…clobber

Prevent Combat Simulator 'Load Settings' clobber
bcachefs directories return stat.st_size=0
last I check: Xbox Controller doesn't refer "Left Stick Click" as "L3".

PlayStation and Steam Deck/Steam Controller (2026) actually refers their variant as "L3".
corrected Xbox pad name (LS Click) on Crouch cycle over at `README.md` Controls page
fgsfdsfgs and others added 28 commits March 23, 2026 10:34
Replace the dead GameView/TouchControls scaffolding (which was wired
against a second GLSurfaceView that MainActivity never actually used)
with a real touch pad that sits on top of the SDL surface and feeds
OSContPad directly:

- port/src/touch.c: JNI bridge that publishes the current virtual-pad
  snapshot (two analog axes + CONT_* button bitmask) with seqlock-style
  double buffering so the game thread never sees a torn state.
- port/src/input.c: inputReadController() calls touchApplyToPad() for
  player 0 so rebinds keep working and a real controller can still
  override the on-screen pad.
- TouchOverlayView: draws the two sticks and the buttons, handles the
  multi-touch logic, and exposes an edit mode where elements drag
  around (or resize, when the RESIZE toggle is on).
- TouchLayout: persistent layout (normalized coordinates) stored in
  SharedPreferences so the user's tweaks survive rotation and reboots.
- LayoutEditorActivity: fullscreen editor with Save / Reset.
- LauncherActivity: always shows the menu with Play + ROM picker +
  "Configure Touch Controls" instead of auto-launching the game.
- .github/workflows/android.yml: CI job that installs NDK 26 + CMake
  3.22 and uploads a debug APK artifact on every push.
The early return at `if (!pads[idx])` meant the on-screen pad only
worked when an Xbox-style controller was also plugged in, which is
the opposite of what we want on Android.
NDK clang chokes on os_cont.h alone because os_message.h (transitively
included) references OSThread. input.c already does this; touch.c/h
needed to mirror it.
The overlay now exposes a floating EDIT pill in the top-right corner:
tapping it freezes input, shows Save/Resize/Reset/Cancel pills across
the top, and turns every element into a draggable/resizable handle.
Save persists to SharedPreferences, Cancel restores the snapshot
taken when edit started. Saved layouts survive quits and reboots.

A new LOOK_PAD element kind replaces the right virtual stick. Within
its rectangle, any drag accumulates into rstick velocity scaled by
screen size, with a 16 ms decay tick so releasing the finger stops
the camera immediately — the same feel as CoD Mobile or Fortnite.

LayoutEditorActivity keeps its own widget-based HUD and now calls
setInternalHudVisible(false) to avoid double UI.
Replaces the fixed LEFT_STICK with a MOVE_PAD — a rectangular zone
where the stick center anchors to wherever you touch down. Drag from
that anchor deflects the stick up to its max radius (a per-element
field, default 0.09 of the shorter screen dimension). Release zeroes
the stick so the character stops immediately.

Same ideia as the LOOK_PAD: you get the whole right half to move,
not a tiny circle to aim your thumb at. Buttons float over the pad
and win hit-test thanks to their later order in the element list.
The rstick is a velocity signal — set it to 0.3 and the camera keeps
rotating as long as the value holds, even after the finger stops.
That is exactly the 'palanca' behavior the user was trying to avoid.

Switch to the same channel real mice use: bondmove.c already calls
inputMouseGetScaledDelta() under allowmlook (which is on during
gameplay), and the default mouseaimmode is MOUSEAIM_CLASSIC. Now
every move-event on the LOOK_PAD adds the pixel delta (scaled by
LOOK_SENS_PX = 0.4) into a native accumulator that inputUpdateMouse
drains once per frame into mouseDX/mouseDY. Consumed, not held —
releasing the finger stops the camera immediately.
Default layout is now the CoD Mobile standard: MOVE pad on the left,
LOOK pad on the right, shooting buttons clustered around the LOOK
pad. Weapon helpers sit over the MOVE pad.

Sensitivity of the look pad moved from a Java compile-time constant
to TouchLayout.lookSens so it persists with the rest of the layout.
The live-edit HUD now has SENS - / SENS + pills around a readout of
the current value, stepping by 0.05 between 0.05 and 2.0.
PD's default CONTROLMODE_PC (enabled when extcontrols is true, which
is the default in PLAYER_EXT_CFG_DEFAULT) routes stick1 to camera
turn/pitch and stick2 to walk/strafe. Our MOVE_PAD was writing to
stick1 (left), which meant dragging the movement pad was turning
the camera and you could never walk anywhere.

Swap MOVE_PAD to feed the right stick instead. LOOK_PAD is
unaffected — it still injects mouse delta, which bondmove already
adds as freelookdx/freelookdy on top of the camera.
- Split lookSens into lookSensX / lookSensY, persisted separately. The
  legacy single _lookSens pref is used as fallback so existing layouts
  keep their current feel.
- Bumped range to 0.05..5.0 (was 0.05..2.0) and step to 0.1, so you
  can reach useful high-turn speeds without 80+ taps.
- Edit HUD is now two rows: actions on top (SAVE / RESIZE / RESET /
  CANCEL), per-axis sens on the bottom (X- 0.40 X+  Y- 0.40 Y+).
- Shrunk default movepad/lookpad hw from 0.25 to 0.23 so the two
  rectangles no longer overlap across the screen midline. Before,
  any touch in that 4%-wide strip went to the lookpad (drawn last)
  instead of the movepad, which looked like a control bug. Hit RESET
  in the editor to pick up the new rectangles.
- Edit HUD now has a ▲ / EDIT ▼ pill in the top-right corner that
  collapses the two-row bar down to just that pill, so you can line
  up buttons under the top row without the bar in the way. Tap the
  pill again to bring the bar back. State resets on each new entry
  into edit mode.

- Move and look pads now default to each covering exactly half the
  screen (cx=0.25 / 0.75, cy=0.50, hw=0.25, hh=0.50). Tap RESET in
  the editor to pick up the new defaults.

- Pad labels moved from the top of the pad to its center so they
  never collide with the START/CANCEL row at the top or the CROUCH
  button at the bottom.
- Tapping a button in edit mode now selects it and shows a per-button
  resize bar (− / r=0.050 / +) right below it (or above, if that
  would fall off-screen). Step is 0.005 per tap over 0.03..0.15. Tap
  anywhere else to clear the selection.
- MOVE_PAD and LOOK_PAD no longer render their rectangle, fill or
  label. They are simply the left and right halves of the screen and
  adapt to whatever surface the phone has. The floating stick knob
  still draws while the user touches the move pad, as feedback.
- Pads are also skipped by the edit hit test (hitTestEditable), so
  the user can't accidentally drag or resize them.
- Removed the global RESIZE toggle (pill in the HUD + setResizeMode
  API + resizeMode field in LayoutEditorActivity). Per-button bar
  replaces it entirely.
Each button now renders an icon that looks like what it does:
  fire    - orange ring around a red bullseye
  aim     - scope reticle (ring + crosshair + center dot)
  use     - open-box arrow
  reload  - 3/4 circular arrow with arrowhead
  altfire - hollow ring + center dot
  wprev   - left-pointing filled triangle
  wnext   - right-pointing filled triangle
  radial  - 3x3 dot grid
  crouch  - down chevron over a floor bar
  start   - pause bars
  cancel  - X mark
Icons auto-scale with the button's radius so they look right at any
size the user picks via the per-button resize bar. Unknown ids still
fall back to a text label.
Solves a issue when using Input Remapper's Mouse-based Reset to Horizon camera function (e.g. Steam Input), where the recenter couldn't stay centered.
To prepare for fgsfdsfgs#627, this commit added Accept/Cancel Swap for Menu Navigation.

This function only applies when using a Nintendo Switch controller, but it can be forcefully enabled for all Controller Types within `pd.ini`'s `swapAcceptCancelButtons`
You can now recenter the camera vertically (similar to id Tech-based games, FEAR and Splatoon). Additionally, for Mouse Input users (and soon: Gyro users, hence this PR uses `cidx`): they can now reset the crosshair to the center!
To differenciate between current and future Input Method or Styles: the Mouse Settings's "Crosshair Speed" now has a "Mouse" at a start of the name. That way: you can easily can tell that this specific slider is specifically for Mouse Input.
this might mess up existing mouse crosshair sens setup in the process
Cherry-picked PR fgsfdsfgs#676 added #include "data.h" after SDL.h, which
works on Linux but chokes on Android because Android NDK's libc
ships overloadable attributes on memcpy / memset / memcmp that
clash with the PR/os_libc.h prototypes data.h transitively pulls
in. Placing data.h first lets the N64 declarations define the
overload set up front so the NDK headers can be pulled in on top
without conflict.
If the ROM already exists in app-scoped storage and its MD5 matches
the recommended NTSC v1.1 build, skip the launcher menu and hand off
straight to MainActivity on app start. The menu with Play / Pick ROM
/ Configure Touch Controls is still the fallback when no valid ROM
is detected; touch-layout editing also remains available in-game via
the EDIT pill.
The overlay (buttons + EDIT pill + floating-stick feedback) now dims
to ~12% alpha after 3s of no touches, snapping back to full on the
next tap. Edit mode disables the fade so the user always sees what
they are editing.

The behavior is persisted on TouchLayout.idleFade (default on) and
toggled from a new FADE: ON / FADE: OFF pill next to the RESET
button in the edit HUD. Animation is driven by postInvalidateOnAnimation
only while the alpha is actively transitioning, so there is no
idle-time battery cost once the fade has settled.
Bumped the inactivity threshold from 3 s to 7 s and dropped the
settled alpha from 0.12 to 0.0 so the overlay becomes fully
invisible once it finishes fading out. Hit-testing still works
through the invisible overlay, so the first tap snaps it back to
full opacity and registers as a button / pad press in the same
gesture.
Spanish, user-facing. Covers: features, requirements, install and
play flow, default layout and the in-game editor, credits to the
upstream decomp team, fgsfdsfgs, izzy2lost, relevant PR authors,
Rare/Nintendo, and Claude. Adds an issues / PR pointer and a
license + known-state section.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces native Android touch controls for the Perfect Dark port, including a live-editable overlay, custom input handling, and necessary adjustments to the input and video systems to support the new platform. My review identified several critical and high-severity issues, including a flawed button-swap logic that forces an A/B swap, a race condition in the Seqlock implementation for touch state, and performance concerns regarding off-screen buffer usage in the overlay. Additionally, I noted issues with input responsiveness due to rounding errors, incorrect global disabling of Raw Input, and confusing UI navigation changes.

Comment thread port/src/input.c
static inline s32 inputBindPressed(const s32 idx, const u32 ck)
s32 inputBindPressed(const s32 idx, const u32 ck)
{
u32 swapped_ck = ck;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Hay un error crítico en la lógica de intercambio de botones A/B. La función inputConfirmCancelButtonSwap devuelve el valor de ck si no hay un intercambio activo o si ck no coincide con los botones de UI. Como ck es un valor distinto de cero (un índice de botón), la condición if siempre se evalúa como verdadera, provocando que los botones A y B se intercambien siempre, independientemente de la configuración del usuario. Además, se está pasando un contkey a una función que espera un bitmask de botón de UI.

	if (inputConfirmCancelSwapActive(idx)) {

Comment thread port/src/touch.c
Comment on lines +111 to +121
atomic_store_explicit(&liveSeq, seq + 1, memory_order_release);

snaps[slot].lx = lx;
snaps[slot].ly = ly;
snaps[slot].rx = rx;
snaps[slot].ry = ry;
snaps[slot].buttons = (u32)buttons;

// publish end (even seq); flip slot for next writer.
atomic_store_explicit(&writeSlot, 1 - slot, memory_order_release);
atomic_store_explicit(&liveSeq, seq + 2, memory_order_release);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

La implementación del Seqlock tiene un riesgo de carrera (race condition) debido al reordenamiento de memoria. atomic_store_explicit con memory_order_release garantiza que las operaciones anteriores no se muevan después del store, pero no impide que las operaciones posteriores (como la escritura en snaps) se muevan antes del primer store a liveSeq. Si esto ocurre, un lector podría leer datos parcialmente escritos mientras liveSeq todavía parece estable (par).

Para una implementación robusta en C11, se recomienda usar memory_order_seq_cst para las actualizaciones de la secuencia o insertar barreras explícitas.

	atomic_store_explicit(&liveSeq, seq + 1, memory_order_seq_cst);

	snaps[slot].lx = lx;
	snaps[slot].ly = ly;
	snaps[slot].rx = rx;
	snaps[slot].ry = ry;
	snaps[slot].buttons = (u32)buttons;

	// publish end (even seq); flip slot for next writer.
	atomic_store_explicit(&writeSlot, 1 - slot, memory_order_release);
	atomic_store_explicit(&liveSeq, seq + 2, memory_order_seq_cst);

Comment on lines +341 to +346
float dx = (x - lookLastX) * layout.lookSensX;
float dy = (y - lookLastY) * layout.lookSensY;
lookLastX = x; lookLastY = y;
int idx = Math.round(dx), idy = Math.round(dy);
if (idx != 0 || idy != 0) {
nativeAddLookDelta(idx, idy);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

La conversión directa de float a int mediante Math.round en cada evento de movimiento provoca una pérdida de precisión significativa, especialmente con sensibilidades bajas. Si el movimiento en un frame es pequeño (por ejemplo, 0.4 píxeles), se redondea a 0 y el movimiento se pierde por completo. Esto hace que los controles se sientan poco responsivos o "stuttery" al realizar movimientos lentos.

Se recomienda acumular el residuo decimal y sumarlo al siguiente evento.

Comment on lines +609 to +610
overlayLayer = canvas.saveLayerAlpha(0, 0, w, h,
(int) (fadeFactor * 255f));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

El uso de canvas.saveLayerAlpha es costoso en términos de rendimiento ya que requiere la creación de un buffer fuera de pantalla (off-screen buffer). Dado que los elementos del overlay (círculos, líneas, iconos) no suelen solaparse de forma compleja, es mucho más eficiente aplicar el factor de transparencia directamente a los objetos Paint utilizados para dibujar cada elemento.

Comment thread port/src/input.c
static s32 useHIDAPI = 1;
#endif
static s32 useRawInput = 1;
static s32 useRawInput = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Desactivar useRawInput de forma global afecta a todas las plataformas (Windows, Linux, etc.). En plataformas de escritorio, el uso de Raw Input es preferible para evitar la aceleración y el escalado del sistema operativo en el mouselook. Este cambio debería estar restringido a Android si es necesario para esa plataforma.

#ifdef ANDROID
static s32 useRawInput = 0;
#else
static s32 useRawInput = 1;
#endif

Comment thread port/src/input.c
Comment on lines 1575 to 1576
} else if (key == VK_ESCAPE) {
return -1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Se ha eliminado el soporte para la tecla VK_RETURN (Enter) para confirmar la entrada de texto, y en menuitem.c se ha cambiado el texto de ayuda a "ESC: OK". Esto es extremadamente contraintuitivo para los usuarios, ya que la tecla Escape se asocia universalmente con la acción de cancelar, no con confirmar. Se recomienda mantener el comportamiento estándar de Enter para confirmar.

Comment thread port/src/optionsmenu.c
Comment on lines +1862 to 1864
s32 displayKey = menuSwapConfirmCancel(binds[data->dropdown.value], menuBinds[idx].ck);

strncpy(keyname, inputGetKeyName(binds[data->dropdown.value]), sizeof(keyname) - 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

La variable displayKey se calcula pero no se utiliza en la llamada a inputGetKeyName. Esto provoca que el nombre del botón mostrado en el menú de configuración no refleje el intercambio de botones A/B si este está activo.

Suggested change
s32 displayKey = menuSwapConfirmCancel(binds[data->dropdown.value], menuBinds[idx].ck);
strncpy(keyname, inputGetKeyName(binds[data->dropdown.value]), sizeof(keyname) - 1);
s32 displayKey = menuSwapConfirmCancel(binds[data->dropdown.value], menuBinds[idx].ck);
strncpy(keyname, inputGetKeyName(displayKey), sizeof(keyname) - 1);

@izzy2lost
Copy link
Copy Markdown
Owner

It's a start. There's a lot I would change though. On screen controls is something I don't like doing. I find it to be difficult. Especially making it editable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants