diff --git a/README.md b/README.md index 9d58381..73c1853 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,13 @@ the same as any other Android Studio project: this automatically otherwise, so plain Java/C++ edits under `android/app/src/main/` just need another Run/Debug press. +**If a native change genuinely doesn't seem to take effect** even after a +normal rebuild - `stageEngine` can wrongly report `UP-TO-DATE` for a +newly-added file under `android/engine-patches/` - run +`android/force-rebuild.sh` and rebuild. For more visibility than Android +Studio's collapsed Build panel, run `./gradlew :app:assembleDebug +--console=plain` from a terminal instead. + ### 5.4. Engine/gl4es patch reference `android/engine-patches/` (applied to a scratch copy of `engine/`) and @@ -397,7 +404,36 @@ each one does: hook: redirects `SDLDraw()`/`opengl_swap_and_restore()` to submit into the XR swapchain (via `xr_frame_begin()`/`xr_frame_end()`) instead of the normal window, for both the GL-rendered 3D path and the plain - software-composited path (splash screen/cutscenes/menus). + software-composited path (splash screen/cutscenes/menus). Also logs + once per transition between the two (`INFO("Game rendering active: + ...")`) for on-device logcat diagnostics. +- **`12-android-texture-row-length.patch`** - `OpenGL.cc`'s wall/floor + texture upload (`opengl_cache_texture()`/`set_texture()`, only reachable + when `gShockPrefs.doUseOpenGL` is true - it defaults `false` in + `Prefs.c`, so this path is dormant on Quest by default) sets + `GL_UNPACK_ROW_LENGTH` from the original 8-bit source bitmap's row + stride (`bm->row`), but the buffer actually uploaded is a separately- + allocated RGBA32 conversion of it with its own, unrelated stride. Fixed + to use the converted buffer's own pitch instead. +- **`13-android-swapchain-sync.patch`** - `android_draw_surface_as_quad()` + used `glFlush()` after drawing into the swapchain's FBO, which queues + the GPU work without waiting for it to finish before + `xr_swapchain_release()` hands the image to the OpenXR runtime for + cross-process consumption. Changed to `glFinish()`, which blocks until + the write has landed. The same gap existed in `xr_overlay.c`'s + `xr_overlay_render_and_build_layer()` (the keyboard/menu overlay blit + path) and was fixed there directly, no patch needed since that file is + first-party. +- **`14-android-quad-unpack-state.patch`** - fixes the diagonal-banded/ + skewed corruption in the 3D game view during real gameplay (see README + 5.5.2). `android_draw_surface_as_quad()`'s `glTexImage2D` upload of the + tightly-packed `uiRgba` buffer relied on GL's default unpack state + (`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting + it explicitly. gl4es implements both client-side and persists whatever + was last set across draw calls in the same context, so this upload + inherited non-default unpack state left behind elsewhere in the frame + and misread every row. Fixed by resetting both to the packed defaults + immediately before this upload. `android/gl4es-patches/`: @@ -459,6 +495,33 @@ trigger (a single always-nonzero struct field) sits well below the project's own layer boundary and doesn't show up in any of the local GL state that the menu's own code controls. +#### 5.5.2. Diagonal-banded corruption in the 3D game view + +During real 3D gameplay (not menus/splash), the game quad rendered as +diagonally-banded color noise. Every OpenXR overlay quad (menu, keyboard, +laser-beam pointers) and the desktop build's software renderer were +unaffected, narrowing the bug to the one present path gameplay uses that +those don't: `android_draw_surface_as_quad()` in `engine/src/MacSrc/ +OpenGL.cc`. + +**Root cause:** its `glTexImage2D` upload of the tightly-packed `uiRgba` +conversion buffer relies on GL's default unpack state +(`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting +it explicitly. gl4es - the GLES/EGL translation layer this engine's +desktop-style GL calls go through on Quest - implements both unpack +parameters in software, manually repacking the source buffer row-by-row +using whatever stride was last set via `glPixelStorei`, and that state +persists across draw calls in the same GL context rather than resetting +per call. During gameplay, this upload inherits non-default unpack state +left behind elsewhere in the frame and misreads every row - a diagonal +shear/banding pattern. Menus never trigger it because nothing in the +menu-only code paths sets a custom unpack stride first. + +**Fix** (`14-android-quad-unpack-state.patch`): explicitly reset both +`GL_UNPACK_ROW_LENGTH` and `GL_UNPACK_ALIGNMENT` to their tightly-packed +values immediately before this `glTexImage2D` call, removing the +dependency on whatever GL state preceded it. + ## 6. License The original tooling in this repository (the Docker build image, build diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index a945bec..470a136 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -4,6 +4,7 @@ // needs, compiled into the same "main" library as engine/ (see // android/engine-patches/01-android-shared-lib.patch's ANDROID_EXTRA_SOURCES) // - engine/ itself is never modified. +#include #include #include @@ -20,17 +21,30 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla } // KeyboardOverlay's hand-built on-screen keyboard (see -// KeyboardOverlay.commitPrintableChar()) has no real Android IME session -// behind it, so printable characters can't reach the game via Android's -// usual IME path - instead this builds and pushes an SDL_TEXTINPUT event -// directly, the same event type/shape SDL's own Android backend pushes for -// typed text, using only the public SDL_Event/SDL_PushEvent API. The -// engine's pump_events() (sdl_events.c) only picks up printable characters -// from this event, not from SDL_KEYDOWN (see handleKeyPress() in -// KeyboardOverlay.java for why). +// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real +// Android IME session behind it, so printable characters can't reach the +// game via Android's usual IME path - instead this builds and pushes an +// SDL_TEXTINPUT event directly, the same event type/shape SDL's own +// Android backend pushes for typed text, using only the public +// SDL_Event/SDL_PushEvent API. The engine's pump_events() (sdl_events.c) +// only picks up printable characters from this event, not from +// SDL_KEYDOWN. +// +// pump_events()'s SDL_TEXTINPUT handling sets sshockKeyStates[code] for +// this character and only ever clears it again on a matching SDL_KEYUP. +// Any character bound to a continuously-polled motion key (space -> jump, +// w -> forward, etc., see MacSrc/Prefs.c's default keybinds.txt) is +// polled every physics tick for as long as that state stays set, exactly +// like a real held key - so nativeSendPrintableCharDown()/Up() are called +// from the key button's own ACTION_DOWN/ACTION_UP (see +// KeyboardOverlay.charKeyTouchListener()), not both at once: pushing the +// release in the same call as the press would set and clear +// sshockKeyStates[code] within a single pump_events() call, before the +// game loop's poll (which runs once per frame, before pump_events - see +// GameSrc/mainloop.c) ever has a chance to observe the press. JNIEXPORT void JNICALL -Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableChar(JNIEnv *env, jclass clazz, - jchar c) { +Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharDown(JNIEnv *env, jclass clazz, + jchar c) { SDL_Event event; SDL_zero(event); event.type = SDL_TEXTINPUT; @@ -41,6 +55,23 @@ Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableChar(JNIEnv *env, j SDL_PushEvent(&event); } +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharUp(JNIEnv *env, jclass clazz, + jchar c) { + SDL_Event releaseEvent; + SDL_zero(releaseEvent); + releaseEvent.type = SDL_KEYUP; + releaseEvent.key.timestamp = SDL_GetTicks(); + releaseEvent.key.windowID = 0; + releaseEvent.key.state = SDL_RELEASED; + releaseEvent.key.repeat = 0; + // SDL keycodes for printable characters are the character's own + // (lowercased) byte value - see sdl_events.c's own comment on this. + releaseEvent.key.keysym.sym = (SDL_Keycode)tolower((unsigned char)c); + releaseEvent.key.keysym.mod = KMOD_NONE; + SDL_PushEvent(&releaseEvent); +} + // KeyboardOverlay's title-bar Close button - the keyboard is a fully // independent overlay quad from the menu launcher (see xr_overlay.h), so // this is the only way to hide it once it's open; the controller's menu diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 086a0ea..3487839 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -6,6 +6,7 @@ #include #include +#include #include "xr_mouse.h" #include "xr_overlay.h" @@ -43,6 +44,18 @@ static XrActionSet g_action_set = XR_NULL_HANDLE; static XrAction g_aim_pose_action = XR_NULL_HANDLE; static XrAction g_select_click_action = XR_NULL_HANDLE; static XrAction g_menu_toggle_action = XR_NULL_HANDLE; +// WORKAROUND - the on-screen keyboard overlay is currently unusable, +// which blocks dismissing the "press Space to continue" tutorial screen +// shown right after starting a new game. This lets the right controller's +// A button send Space straight into the engine, bypassing the broken +// keyboard entirely. Remove once the keyboard overlay works. +static XrAction g_space_key_action = XR_NULL_HANDLE; +// Right controller's B button -> forward movement (Up arrow, one of the +// engine's default M_FORWARD keybinds per MacSrc/Prefs.c). Unlike Space, +// Up arrow is non-printable so a plain SDL_KEYDOWN/KEYUP is enough - no +// SDL_TEXTINPUT needed (see the space_key injection below for why that +// matters). +static XrAction g_forward_key_action = XR_NULL_HANDLE; static XrPath g_hand_path[2]; static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE}; @@ -53,6 +66,8 @@ static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE}; // be visible at once. static bool g_prev_select[2] = {false, false}; static bool g_prev_menu = false; +static bool g_prev_space_key = false; +static bool g_prev_forward_key = false; static bool g_game_prev_hit[2] = {false, false}; static bool g_game_touch_active[2] = {false, false}; static bool g_menu_prev_hit[2] = {false, false}; @@ -229,6 +244,30 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for "xrCreateAction(menu_toggle)")) return false; + // WORKAROUND - right controller's A button, see comment on + // g_space_key_action's declaration above. + XrActionCreateInfo spaceKeyInfo = {XR_TYPE_ACTION_CREATE_INFO}; + strncpy(spaceKeyInfo.actionName, "space_key", XR_MAX_ACTION_NAME_SIZE - 1); + strncpy(spaceKeyInfo.localizedActionName, "Space Key", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); + spaceKeyInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; + spaceKeyInfo.countSubactionPaths = 1; + spaceKeyInfo.subactionPaths = &g_hand_path[RIGHT]; + if (!xr_check(xrCreateAction(g_action_set, &spaceKeyInfo, &g_space_key_action), + "xrCreateAction(space_key)")) + return false; + + // Right controller's B button, see comment on g_forward_key_action's + // declaration above. + XrActionCreateInfo forwardKeyInfo = {XR_TYPE_ACTION_CREATE_INFO}; + strncpy(forwardKeyInfo.actionName, "forward_key", XR_MAX_ACTION_NAME_SIZE - 1); + strncpy(forwardKeyInfo.localizedActionName, "Forward Key", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); + forwardKeyInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; + forwardKeyInfo.countSubactionPaths = 1; + forwardKeyInfo.subactionPaths = &g_hand_path[RIGHT]; + if (!xr_check(xrCreateAction(g_action_set, &forwardKeyInfo, &g_forward_key_action), + "xrCreateAction(forward_key)")) + return false; + XrPath profilePath; xrStringToPath(instance, "/interaction_profiles/oculus/touch_controller", &profilePath); @@ -240,17 +279,22 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for // action. Suggesting a binding to a component the profile doesn't // have at all (like a nonexistent .../trigger/click) fails the entire // xrSuggestInteractionProfileBindings call, not just that one entry. - XrPath aimPoseLeftPath, aimPoseRightPath, triggerLeftPath, triggerRightPath, menuClickPath; + XrPath aimPoseLeftPath, aimPoseRightPath, triggerLeftPath, triggerRightPath, menuClickPath, + aClickPath, bClickPath; xrStringToPath(instance, "/user/hand/left/input/aim/pose", &aimPoseLeftPath); xrStringToPath(instance, "/user/hand/right/input/aim/pose", &aimPoseRightPath); xrStringToPath(instance, "/user/hand/left/input/trigger/value", &triggerLeftPath); xrStringToPath(instance, "/user/hand/right/input/trigger/value", &triggerRightPath); xrStringToPath(instance, "/user/hand/left/input/menu/click", &menuClickPath); + xrStringToPath(instance, "/user/hand/right/input/a/click", &aClickPath); + xrStringToPath(instance, "/user/hand/right/input/b/click", &bClickPath); XrActionSuggestedBinding bindings[] = { {g_aim_pose_action, aimPoseLeftPath}, {g_aim_pose_action, aimPoseRightPath}, {g_select_click_action, triggerLeftPath}, {g_select_click_action, triggerRightPath}, {g_menu_toggle_action, menuClickPath}, + {g_space_key_action, aClickPath}, + {g_forward_key_action, bClickPath}, }; XrInteractionProfileSuggestedBinding suggested = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; suggested.interactionProfile = profilePath; @@ -291,7 +335,7 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for } } - LOGI("XR: input action set ready (aim pose + trigger + menu-toggle)"); + LOGI("XR: input action set ready (aim pose + trigger + menu-toggle + A=space + B=forward)"); return true; } @@ -356,8 +400,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand, *outPlaneDistance = t; if (hit != *prevHit) { - LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand], - hit ? "entered" : "left", quadName, u, v); + // Disabled for now - noisy while chasing an unrelated texture bug. + // LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand], + // hit ? "entered" : "left", quadName, u, v); *prevHit = hit; } if (hit) { @@ -559,8 +604,9 @@ static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, fl *outV = v; if (hit != g_game_prev_hit[hand]) { - LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand], - hit ? "entered" : "left", u, v); + // Disabled for now - noisy while chasing an unrelated texture bug. + // LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand], + // hit ? "entered" : "left", u, v); g_game_prev_hit[hand] = hit; } return hit; @@ -655,6 +701,82 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { g_prev_menu = menuDown; } + if (hand == RIGHT) { + XrActionStateGetInfo spaceKeyInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; + spaceKeyInfo.action = g_space_key_action; + spaceKeyInfo.subactionPath = g_hand_path[RIGHT]; + XrActionStateBoolean spaceKeyState = {XR_TYPE_ACTION_STATE_BOOLEAN}; + xrGetActionStateBoolean(g_session, &spaceKeyInfo, &spaceKeyState); + bool spaceKeyDown = spaceKeyState.isActive && spaceKeyState.currentState; + if (spaceKeyDown != g_prev_space_key) { + LOGI("XR: A button (space key) %s", spaceKeyDown ? "DOWN" : "UP"); + if (spaceKeyDown) { + // The engine's pump_events() (sdl_events.c) only queues a + // KEYDOWN here for non-printable keys (ascii < 32) - + // printable keys like Space are expected to arrive via + // SDL_TEXTINPUT instead (that's the branch that actually + // queues the KBS_DOWN event the tutorial-dismiss handler + // checks for), so send that too, matching what real + // keyboard input generates for a Space press. + // + // sdl_events.c's SDL_TEXTINPUT handling also sets + // sshockKeyStates[Space] = pressed - the same array + // Space's own keybind (M_JUMP, see MacSrc/Prefs.c's + // default keybinds.txt) polls every physics tick via + // ui_poll_keyboard(). It's only ever cleared again by a + // matching SDL_KEYUP, sent below once this button's real + // physical up-edge fires (spaceKeyDown false) - not + // synthesized here alongside the press, since pushing + // both in the same batch would set and clear it within + // one pump_events() call, before the poll (which runs + // once per frame, before pump_events) ever sees the + // press. + SDL_Event textEvent; + SDL_zero(textEvent); + textEvent.type = SDL_TEXTINPUT; + textEvent.text.timestamp = SDL_GetTicks(); + textEvent.text.windowID = 0; + textEvent.text.text[0] = ' '; + textEvent.text.text[1] = '\0'; + SDL_PushEvent(&textEvent); + } + SDL_Event event; + SDL_zero(event); + event.type = spaceKeyDown ? SDL_KEYDOWN : SDL_KEYUP; + event.key.timestamp = SDL_GetTicks(); + event.key.windowID = 0; + event.key.state = spaceKeyDown ? SDL_PRESSED : SDL_RELEASED; + event.key.repeat = 0; + event.key.keysym.scancode = SDL_SCANCODE_SPACE; + event.key.keysym.sym = SDLK_SPACE; + event.key.keysym.mod = KMOD_NONE; + SDL_PushEvent(&event); + g_prev_space_key = spaceKeyDown; + } + + XrActionStateGetInfo forwardKeyInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; + forwardKeyInfo.action = g_forward_key_action; + forwardKeyInfo.subactionPath = g_hand_path[RIGHT]; + XrActionStateBoolean forwardKeyState = {XR_TYPE_ACTION_STATE_BOOLEAN}; + xrGetActionStateBoolean(g_session, &forwardKeyInfo, &forwardKeyState); + bool forwardKeyDown = forwardKeyState.isActive && forwardKeyState.currentState; + if (forwardKeyDown != g_prev_forward_key) { + LOGI("XR: B button (forward key) %s", forwardKeyDown ? "DOWN" : "UP"); + SDL_Event event; + SDL_zero(event); + event.type = forwardKeyDown ? SDL_KEYDOWN : SDL_KEYUP; + event.key.timestamp = SDL_GetTicks(); + event.key.windowID = 0; + event.key.state = forwardKeyDown ? SDL_PRESSED : SDL_RELEASED; + event.key.repeat = 0; + event.key.keysym.scancode = SDL_SCANCODE_UP; + event.key.keysym.sym = SDLK_UP; + event.key.keysym.mod = KMOD_NONE; + SDL_PushEvent(&event); + g_prev_forward_key = forwardKeyDown; + } + } + XrActionStateGetInfo poseInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; poseInfo.action = g_aim_pose_action; poseInfo.subactionPath = g_hand_path[hand]; @@ -782,6 +904,8 @@ void xr_input_shutdown(void) { g_aim_pose_action = XR_NULL_HANDLE; g_select_click_action = XR_NULL_HANDLE; g_menu_toggle_action = XR_NULL_HANDLE; + g_space_key_action = XR_NULL_HANDLE; + g_forward_key_action = XR_NULL_HANDLE; g_instance = XR_NULL_HANDLE; g_session = XR_NULL_HANDLE; @@ -794,6 +918,8 @@ void xr_input_shutdown(void) { memset(g_keyboard_touch_active, 0, sizeof(g_keyboard_touch_active)); g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false; g_prev_menu = false; + g_prev_space_key = false; + g_prev_forward_key = false; g_keyboard_dragging = false; g_keyboard_drag_hand = -1; } diff --git a/android/app/src/main/cpp/xr_mouse.h b/android/app/src/main/cpp/xr_mouse.h index 263f217..38e9e58 100644 --- a/android/app/src/main/cpp/xr_mouse.h +++ b/android/app/src/main/cpp/xr_mouse.h @@ -21,8 +21,9 @@ extern "C" { void xr_mouse_move(int x, int y); // Pushes a synthetic SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) - matches -// KeyboardOverlay's nativeSendPrintableChar() in questshock_native.c: a -// plain SDL_Event built by hand and handed to the public SDL_PushEvent(), +// KeyboardOverlay's nativeSendPrintableCharDown()/Up() in +// questshock_native.c: a plain SDL_Event built by hand and handed to the +// public SDL_PushEvent(), // no engine patch needed. pump_events() (sdl_events.c) reads whatever // mouse_put_xy() last set, not any x/y carried on the event itself - call // xr_mouse_move() first if position needs updating this frame. diff --git a/android/app/src/main/cpp/xr_overlay.c b/android/app/src/main/cpp/xr_overlay.c index 4372584..a788054 100644 --- a/android/app/src/main/cpp/xr_overlay.c +++ b/android/app/src/main/cpp/xr_overlay.c @@ -427,6 +427,15 @@ bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQua overlay->width, 0, GL_COLOR_BUFFER_BIT, GL_LINEAR); real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo); + // Without this, nothing here waits for the blit to actually finish on + // the GPU before xr_swapchain_release() hands the image back to the + // compositor - glBlitFramebuffer only queues the copy. A cross-process + // consumer reading the image before the driver has finished writing it + // is a textbook source of the diagonal-banded/torn corruption seen on + // device (see xr_swapchain_acquire()'s counterpart in OpenGL.cc, which + // had the same gap). + glFinish(); + xr_swapchain_release(overlay->instance, &overlay->swapchain); memset(out_quad, 0, sizeof(*out_quad)); diff --git a/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java b/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java index df889dd..882b73e 100644 --- a/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.util.TypedValue; import android.view.Gravity; import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -17,13 +18,19 @@ import org.libsdl.app.SDLActivity; * The on-screen keyboard quad (see OverlayPanel for the shared off-screen- * render/touch/cursor plumbing this builds on) - a hand-built, full * US-layout key grid (there's no system IME to borrow once immersive) plus - * a text field echoing what's been sent. Each key press is forwarded - * straight to the game as real input (see handleKeyPress()/ - * handleCharKeyPress()) - non-printable keys as an + * a text field echoing what's been sent. Every key that reaches the game + * (everything but Close/Shift/Ctrl/Alt, which are local UI actions - see + * handleKeyDown()/handleKeyUp() and handleCharKeyDown()/handleCharKeyUp()) + * is driven by that key button's own ACTION_DOWN/ACTION_UP (see + * charKeyTouchListener()/controlKeyTouchListener()), which + * OverlayPanel.handleTouch() dispatches for exactly as long as the + * controller's trigger is physically held while aimed at it - so a key + * held on this keyboard behaves like a real key held on a physical one, + * not an instant tap. Non-printable keys go through an * SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a - * physical Bluetooth keyboard's presses already drive, and printable keys - * via a synthesized SDL_TEXTINPUT event pushed from native code (see - * commitPrintableChar()/nativeSendPrintableChar() in questshock_native.c) - + * physical Bluetooth keyboard's presses already drive; printable keys go + * through a synthesized SDL_TEXTINPUT/SDL_KEYUP pair pushed from native + * code (see nativeSendPrintableCharDown()/Up() in questshock_native.c) - * except while a Ctrl/Alt "armed" modifier is active, when they go through * the same onNativeKeyDown()/onNativeKeyUp() path instead, so the modifier * carries across (see toggleModifier()). Shift is a purely local layer @@ -93,9 +100,9 @@ public class KeyboardOverlay extends OverlayPanel { // A key whose printed character (and, while unmodified, whose game // input) depends on the Shift layer - covers letters, digits, and // punctuation uniformly (e.g. 'q'/'Q', '1'/'!', '-'/'_'). androidKeyCode - // is only used for the Ctrl/Alt-combo path (see handleCharKeyPress()) - - // the unmodified path goes through commitPrintableChar() instead, which - // doesn't need an Android keycode at all. + // is only used for the Ctrl/Alt-combo path (see handleCharKeyDown()) - + // the unmodified path goes through nativeSendPrintableCharDown()/Up() + // instead, which doesn't need an Android keycode at all. private static final class CharKey { final char base; final char shifted; @@ -224,7 +231,7 @@ public class KeyboardOverlay extends OverlayPanel { Button close = new Button(activity); close.setText("Close"); - close.setOnClickListener(v -> handleKeyPress("Close")); + close.setOnClickListener(v -> nativeRequestClose()); close.setOnTouchListener(CLICK_ON_TOUCH_UP); bar.addView(close, new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT)); @@ -234,9 +241,10 @@ public class KeyboardOverlay extends OverlayPanel { // Each row fills the panel's remaining height evenly (weight 1 on the // row itself). Accepts a mix of String (a fixed-action control key, - // handled by handleKeyPress()) and CharKey (a Shift-sensitive character - // key, handled by handleCharKeyPress()) per item, so a row can match - // real keyboard geometry (e.g. "Tab" followed by a run of CharKeys). + // handled by handleKeyDown()/handleKeyUp()) and CharKey (a + // Shift-sensitive character key, handled by handleCharKeyDown()/ + // handleCharKeyUp()) per item, so a row can match real keyboard + // geometry (e.g. "Tab" followed by a run of CharKeys). // Within a row, CharKeys and most control keys share width equally // except Space (wider, like a real keyboard) and Esc/Back/Enter/Tab/ // Shift/Ctrl/Alt (narrower, see keyWeight()). @@ -245,13 +253,12 @@ public class KeyboardOverlay extends OverlayPanel { row.setOrientation(LinearLayout.HORIZONTAL); for (Object item : items) { Button key = new Button(activity); - key.setOnTouchListener(CLICK_ON_TOUCH_UP); float weight; if (item instanceof CharKey) { CharKey charKey = (CharKey) item; charKey.button = key; key.setText(charKeyLabel(charKey, shiftActive)); - key.setOnClickListener(v -> handleCharKeyPress(charKey)); + key.setOnTouchListener(charKeyTouchListener(charKey)); charKeys.add(charKey); // Space is a CharKey too (base == shifted == ' '), but keeps // its traditional wide key like the other control keys do. @@ -259,8 +266,19 @@ public class KeyboardOverlay extends OverlayPanel { } else { String label = (String) item; key.setText(label); - key.setOnClickListener(v -> handleKeyPress(label)); weight = keyWeight(label); + // Shift/Ctrl/Alt are local UI toggles with their own + // instantaneous press/release semantics (see + // toggleModifier()), not a value forwarded to the game for + // as long as the key is held - a plain click is enough. + // Every other control key reaches the game and needs real + // hold-duration semantics (see controlKeyTouchListener()). + if (label.equals("Shift") || label.equals("Ctrl") || label.equals("Alt")) { + key.setOnClickListener(v -> handleToggleKey(label)); + key.setOnTouchListener(CLICK_ON_TOUCH_UP); + } else { + key.setOnTouchListener(controlKeyTouchListener(label)); + } if (label.equals("Shift")) { shiftButton = key; } else if (label.equals("Ctrl")) { @@ -276,6 +294,54 @@ public class KeyboardOverlay extends OverlayPanel { ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); } + // Drives a control key (everything controlKeyCode() maps, i.e. every + // key in addRow() except CharKeys and the Shift/Ctrl/Alt toggles) from + // its button's own touch lifecycle, so it reaches the game for exactly + // as long as the controller's trigger is held on it (see the class + // doc). ACTION_CANCEL (the ray straying off the button, or another + // overlay claiming it - see xr_input.c) releases the key the same as + // ACTION_UP, so a key can never be left stuck down. + private View.OnTouchListener controlKeyTouchListener(String label) { + return (v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + v.setPressed(true); + handleKeyDown(label); + break; + case MotionEvent.ACTION_UP: + v.setPressed(false); + handleKeyUp(label); + break; + case MotionEvent.ACTION_CANCEL: + v.setPressed(false); + handleKeyUp(label); + break; + } + return true; + }; + } + + // Same as controlKeyTouchListener() above, for a CharKey. + private View.OnTouchListener charKeyTouchListener(CharKey key) { + return (v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + v.setPressed(true); + handleCharKeyDown(key); + break; + case MotionEvent.ACTION_UP: + v.setPressed(false); + handleCharKeyUp(key); + break; + case MotionEvent.ACTION_CANCEL: + v.setPressed(false); + handleCharKeyUp(key); + break; + } + return true; + }; + } + private static float keyWeight(String label) { if (label.equals("Back") || label.equals("Esc") || label.equals("Enter") || label.equals("Tab") || label.equals("Shift") || label.equals("Ctrl") @@ -310,38 +376,31 @@ public class KeyboardOverlay extends OverlayPanel { } } - // "Close" (the title bar's button - see buildTitleBar()) hides this - // overlay's quad entirely (see nativeRequestClose()) and never reaches - // the game. Shift/Ctrl/Alt are handled separately below (Shift is a - // local layer toggle; Ctrl/Alt are one-shot armed modifiers) since - // neither sends a plain key event of its own the way every other - // control key here does. Everything else (Back/Esc/Enter/Tab/arrows/ - // F1-F12) is non-printable, so it's forwarded as a real Android key - // event via controlKeyCode() above, then releases any armed Ctrl/Alt - // modifier (this key just consumed it). This all matters beyond just - // the visible typedTextView below: the engine's cutscene skip handler - // (cutsloop.c's cutscene_key_handler()) only reacts to Esc/Enter/Space, - // so those need to reach the game as actual game input, not just be - // echoed locally. - private void handleKeyPress(String label) { - if (label.equals("Close")) { - nativeRequestClose(); - return; - } + // Shift is a local layer toggle; Ctrl/Alt are one-shot armed modifiers + // (see toggleModifier()) - neither is forwarded to the game as a key of + // its own, so a plain click (rather than the hold-duration dispatch + // every other key below uses) is enough. + private void handleToggleKey(String label) { if (label.equals("Shift")) { shiftActive = !shiftActive; updateShiftVisual(); return; } - if (label.equals("Ctrl") || label.equals("Alt")) { - toggleModifier(label); - return; - } + toggleModifier(label); + } + // Everything controlKeyCode() maps (Back/Esc/Enter/Tab/arrows/F1-F12) + // is non-printable, so it's forwarded as a real Android key event via + // that code - down on this key's own ACTION_DOWN, up on ACTION_UP/ + // CANCEL (see controlKeyTouchListener()), the same down/up split a + // physical Bluetooth keyboard's own key events already carry. This all + // matters beyond just the visible typedTextView below: the engine's + // cutscene skip handler (cutsloop.c's cutscene_key_handler()) only + // reacts to Esc/Enter/Space, so those need to reach the game as actual + // game input, not just be echoed locally. + private void handleKeyDown(String label) { int keyCode = controlKeyCode(label); SDLActivity.onNativeKeyDown(keyCode); - SDLActivity.onNativeKeyUp(keyCode); - releaseArmedModifiers(); if (label.equals("Back")) { if (typedText.length() > 0) { @@ -353,31 +412,45 @@ public class KeyboardOverlay extends OverlayPanel { typedTextView.setText(typedText.toString()); } + private void handleKeyUp(String label) { + SDLActivity.onNativeKeyUp(controlKeyCode(label)); + releaseArmedModifiers(); + } + // A CharKey's printed character depends only on the Shift layer // (updateShiftVisual() keeps its button's label in sync). Which game // input it produces depends on whether a modifier is armed: unmodified, - // it goes through commitPrintableChar() below like a normal typed - // character; with Ctrl/Alt armed, it instead goes through the same - // onNativeKeyDown()/onNativeKeyUp() path handleKeyPress() uses for - // control keys, since that's the only path that carries - // ev.key.keysym.mod through to the engine (sdl_events.c's pump_events() - // reads Ctrl/Alt only off that path, never off SDL_TEXTINPUT). - private void handleCharKeyPress(CharKey key) { + // it goes through nativeSendPrintableCharDown()/Up() below like a + // normal typed character; with Ctrl/Alt armed, it instead goes through + // the same onNativeKeyDown()/onNativeKeyUp() path handleKeyDown()/ + // handleKeyUp() use for control keys, since that's the only path that + // carries ev.key.keysym.mod through to the engine (sdl_events.c's + // pump_events() reads Ctrl/Alt only off that path, never off + // SDL_TEXTINPUT). + private void handleCharKeyDown(CharKey key) { char c = shiftActive ? key.shifted : key.base; if (ctrlArmed || altArmed) { SDLActivity.onNativeKeyDown(key.androidKeyCode); - SDLActivity.onNativeKeyUp(key.androidKeyCode); - releaseArmedModifiers(); } else { - commitPrintableChar(c); + nativeSendPrintableCharDown(c); } typedText.append(c); typedTextView.setText(typedText.toString()); } + private void handleCharKeyUp(CharKey key) { + char c = shiftActive ? key.shifted : key.base; + if (ctrlArmed || altArmed) { + SDLActivity.onNativeKeyUp(key.androidKeyCode); + releaseArmedModifiers(); + } else { + nativeSendPrintableCharUp(c); + } + } + // Ctrl/Alt are "armed" rather than held: pressing one immediately sends // its keydown (so SDL's own modifier tracking picks it up for whatever - // key comes next - see handleCharKeyPress()/handleKeyPress()) and + // key comes next - see handleCharKeyDown()/handleKeyDown()) and // brackets its label for feedback; pressing the same key again before // it's been used cancels it (sends the matching keyup, un-brackets). // The normal case - actually being consumed by the next key press - is @@ -400,10 +473,10 @@ public class KeyboardOverlay extends OverlayPanel { button.setText(nowArmed ? "[" + label + "]" : label); } - // Called after any key actually reaches the game (a control key in - // handleKeyPress(), or a CharKey in handleCharKeyPress()) so an armed - // Ctrl/Alt only ever applies to the very next key, then releases - - // matching a real Ctrl/Alt+key combo's keyup once the combo is done. + // Called after any key actually reaches the game (a control key's + // handleKeyUp(), or a CharKey's handleCharKeyUp()) so an armed Ctrl/Alt + // only ever applies to the very next key, then releases - matching a + // real Ctrl/Alt+key combo's keyup once the combo is done. private void releaseArmedModifiers() { if (ctrlArmed) { SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_CTRL_LEFT); @@ -429,17 +502,15 @@ public class KeyboardOverlay extends OverlayPanel { } } - // Synthesizes the SDL_TEXTINPUT event pump_events() (sdl_events.c) - // requires for printable characters (see handleCharKeyPress() above), - // by building and pushing it directly in native code (see - // nativeSendPrintableChar() in questshock_native.c) rather than through - // Android's IME (there's no real IME session behind this off-screen, + // Synthesizes the SDL_TEXTINPUT/SDL_KEYUP pair pump_events() + // (sdl_events.c) requires for printable characters (see + // handleCharKeyDown()/handleCharKeyUp() above), by building and + // pushing them directly in native code rather than through Android's + // IME (there's no real IME session behind this off-screen, // never-attached grid for that plumbing to hook into). - private static void commitPrintableChar(char c) { - nativeSendPrintableChar(c); - } + private static native void nativeSendPrintableCharDown(char c); - private static native void nativeSendPrintableChar(char c); + private static native void nativeSendPrintableCharUp(char c); // Hides this overlay's quad (xr_overlay_set_visible(..., false)) - the // only way to do so, since the controller's menu button doesn't affect diff --git a/android/engine-patches/11-android-openxr-present.patch b/android/engine-patches/11-android-openxr-present.patch index 6c2bd4d..bf5d311 100644 --- a/android/engine-patches/11-android-openxr-present.patch +++ b/android/engine-patches/11-android-openxr-present.patch @@ -199,11 +199,18 @@ #include "InitMac.h" #include "Modding.h" #include "OpenGL.h" -@@ -301,6 +305,21 @@ +@@ -301,6 +305,28 @@ } void SDLDraw() { +#ifdef __ANDROID__ ++ // Diagnostic: logs once per transition (including the very first ++ // frame) between the OpenGL 3D render path and the plain ++ // software-composited 2D path (splash/menus/cutscenes) below - so a ++ // logcat capture immediately shows which one is active without ++ // needing to infer it from screenshots. ++ static int s_last_render_mode = -1; // -1 = not yet logged ++ + // The true once-a-frame present hook for the immersive OpenXR path - + // unlike opengl_swap_and_restore() below, this runs every frame + // regardless of should_opengl_swap() (e.g. also during the splash @@ -221,17 +228,25 @@ if (should_opengl_swap()) { // We want the UI background to be transparent! sdlPalette->colors[255].a = 0x00; -@@ -310,14 +329,27 @@ +@@ -310,14 +336,35 @@ // Set the palette back, and we are done sdlPalette->colors[255].a = 0xff; +#ifdef __ANDROID__ ++ if (s_last_render_mode != 1) { ++ s_last_render_mode = 1; ++ INFO("Game rendering active: OpenGL (3D) mode"); ++ } + xr_frame_end(); +#endif return; } +#ifdef __ANDROID__ ++ if (s_last_render_mode != 0) { ++ s_last_render_mode = 0; ++ INFO("Game rendering active: software (2D) mode"); ++ } + // Same software-rendered frame the desktop path below would present + // via SDL_RenderCopy/SDL_RenderPresent, composited into the XR + // swapchain image xr_frame_begin() bound above instead - SDL's diff --git a/android/engine-patches/12-android-texture-row-length.patch b/android/engine-patches/12-android-texture-row-length.patch new file mode 100644 index 0000000..9e69c5d --- /dev/null +++ b/android/engine-patches/12-android-texture-row-length.patch @@ -0,0 +1,30 @@ +--- a/src/MacSrc/OpenGL.cc 2026-08-22 09:00:00.000000000 +0200 ++++ b/src/MacSrc/OpenGL.cc 2026-08-22 09:05:00.000000000 +0200 +@@ -562,7 +562,14 @@ + static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) { + SDL_GL_MakeCurrent(window, context); + +- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row); ++ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to ++ // glTexImage2D() below (toCache.converted->pixels, a separately ++ // allocated SDL_PIXELFORMAT_RGBA32 surface from ++ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit ++ // source bitmap's own unrelated row stride. Using bm->row here fed ++ // gl4es's GL_UNPACK_ROW_LENGTH emulation the wrong stride whenever ++ // the two differed, corrupting the texture upload. ++ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4); + + if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) { + // We have enough room, generate the new texture +@@ -696,7 +703,10 @@ + t->lastDrawTime = *tmd_ticks; + + if (isDirty) { +- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row); ++ // Same fix as opengl_cache_texture() above - t->converted->pixels ++ // is what's actually uploaded, so its own pitch is what ++ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row. ++ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels); + } + } diff --git a/android/engine-patches/13-android-swapchain-sync.patch b/android/engine-patches/13-android-swapchain-sync.patch new file mode 100644 index 0000000..0103735 --- /dev/null +++ b/android/engine-patches/13-android-swapchain-sync.patch @@ -0,0 +1,15 @@ +--- a/src/MacSrc/OpenGL.cc ++++ b/src/MacSrc/OpenGL.cc +@@ -566,7 +566,11 @@ + glDisableVertexAttribArray(0); + glDisableVertexAttribArray(tcAttrib); + glDisableVertexAttribArray(lightAttrib); +- glFlush(); ++ // glFinish(), not glFlush(): xr_swapchain_release() (called by our ++ // caller right after this returns) hands the image to the OpenXR ++ // runtime for cross-process consumption, which requires the GPU write ++ // to have actually landed, not just be queued. ++ glFinish(); + + SDL_FreeSurface(uiRgba); + } diff --git a/android/engine-patches/14-android-quad-unpack-state.patch b/android/engine-patches/14-android-quad-unpack-state.patch new file mode 100644 index 0000000..86bbec2 --- /dev/null +++ b/android/engine-patches/14-android-quad-unpack-state.patch @@ -0,0 +1,16 @@ +--- a/src/MacSrc/OpenGL.cc ++++ b/src/MacSrc/OpenGL.cc +@@ -525,6 +525,13 @@ + bind_texture(s_uiTexture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ // uiRgba is tightly packed, but gl4es implements GL_UNPACK_ROW_LENGTH/ ++ // GL_UNPACK_ALIGNMENT client-side and persists whatever was last set ++ // across draw calls in the same context. Reset both to the packed ++ // defaults here so this upload doesn't inherit unrelated unpack state ++ // set elsewhere in the frame. ++ glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); ++ glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, + uiRgba->pixels); + diff --git a/android/force-rebuild.sh b/android/force-rebuild.sh new file mode 100755 index 0000000..ff31cb9 --- /dev/null +++ b/android/force-rebuild.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Forces a fully fresh native build - works around stageEngine sometimes +# wrongly reporting UP-TO-DATE after android/engine-patches/ or +# android/gl4es-patches/ changes (see gradle_stage_engine_stale_cache memory +# note). Run from anywhere. Needs JAVA_HOME set to a JDK 17+; defaults to +# Android Studio's bundled JDK if found. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [ -z "${JAVA_HOME:-}" ]; then + if [ -d /opt/android-studio/jbr ]; then + export JAVA_HOME=/opt/android-studio/jbr + else + echo "JAVA_HOME not set and no JDK found at /opt/android-studio/jbr - export it yourself" >&2 + exit 1 + fi +fi + +./gradlew --stop +rm -rf \ + ../build/android-engine \ + ../build/android-gl4es \ + ../build/android-prebuilt \ + app/.cxx \ + app/build +echo "Done - next Gradle build/Run will rebuild from scratch." diff --git a/res/screenshots/gallery_004.jpg b/res/screenshots/gallery_004.jpg deleted file mode 100644 index 16cd7f5..0000000 Binary files a/res/screenshots/gallery_004.jpg and /dev/null differ diff --git a/res/screenshots/gallery_007.jpg b/res/screenshots/gallery_007.jpg new file mode 100644 index 0000000..85bc206 Binary files /dev/null and b/res/screenshots/gallery_007.jpg differ diff --git a/res/screenshots/gallery_008.jpg b/res/screenshots/gallery_008.jpg new file mode 100644 index 0000000..08a0871 Binary files /dev/null and b/res/screenshots/gallery_008.jpg differ diff --git a/res/screenshots/gallery_009.jpg b/res/screenshots/gallery_009.jpg new file mode 100644 index 0000000..4c89a60 Binary files /dev/null and b/res/screenshots/gallery_009.jpg differ