43 Commits

Author SHA1 Message Date
ml ab2b778f44 Add AI-upscaled critter/object art as a new MDL mod type
build / build (push) Successful in 3m36s
Extends the MDL system beyond text overrides: bitmaps/<label>.json
names a model/scale and resource list, and mdlp extracts each
resource's frames, runs them through an ONNX Runtime CPU upscaler
baked into mdlp-image, re-quantizes to the game's palette, and writes
the result back - same build-time pipeline as message overrides.

Adds animgallery, a standalone viewer that renders any data
directory's animated resources as an HTML/GIF gallery with a
checkbox-driven MDL JSON generator, and -only-changed to preview just
what a mod overrides against the base game.

Fixes a real data race in hacked's ChangeNotifier.hashAll() (found via
go build -race causing non-deterministic resource counts), via a new
numbered patch rather than editing the vendored source.

Adds engine-patches/, a general-fixes counterpart to
android/engine-patches/ now staged and applied to the desktop builds
too (previously unpatched): a real GL_UNPACK_ROW_LENGTH stride bug, an
audio error-logging improvement, and two critter-rendering fixes -
mod art was losing to the base game's own re-opened resource files,
and critters rendered at the wrong size/position once that was fixed,
both from how the billboard renderer derives on-screen size and
placement straight from bitmap pixel dimensions.

Updates README.md and AGENTS.md to document engine-patches/.
2026-09-27 20:52:35 +02:00
ml 389abd7de7 Add a first-party MDL mod-build system and reorganize modding/ under src/
modding/mods/ holds message edits as plain JSON (type, index, language,
plus only the changed fields - a long body can live in a sibling .txt to
avoid escaping in JSON), so mod authoring is reviewable in git instead of
living only in hacked's GUI. mdlp (modding/src/mdlp/, its own Docker image
mdlp-image/) turns them into the same mod folder/zip hacked's own Save
produces, reusing hacked's ss1/edit, ss1/world and ss1/content/text
packages via a go.mod replace - no GUI/GLFW/GTK needed. `make mods`
builds every mod into dist/mods/<name>/, scoped per-mod so it never
touches hacked's own build output; `make` (no target) now builds both.

Fixes a bug this surfaced: hacked's FileLocation.AbsolutePathFrom
silently writes into the current directory instead of the given output
folder when passed a relative path (the GUI never hits this since its
folder picker is always absolute) - mdlp now resolves its own paths
up front.

res/run-mods.sh/.bat are new companion launchers to run.sh/.bat: they
pass every dist/mods/*/mod folder to the engine as a separate absolute
argument, since ProcessModDirectory() only scans one directory level
deep. Wired into `make dist`/`dist-win` only, not the release archives.

modding/hacked/, its patches, sqweek-dialog/, its patches, and the new
mdlp/ move under modding/src/, separating source from the surrounding
Docker/script scaffolding; all path references updated to match.

NOTICE.txt gains a section for hacked's distributed build, listing hacked
(New BSD), sqweek/dialog (ISC - corrected from a wrong "New BSD" note),
and hacked's other direct Go dependencies.
2026-09-26 15:41:56 +02:00
ml c0952898ae Moving some directories 2026-09-26 15:20:07 +02:00
ml e410d82680 Fix hacked GTK3/GLFW crash by vendoring a patched sqweek/dialog
The built hacked binary segfaults on Linux the moment a native GTK3
dialog (file/folder browse) opens while a GLFW/OpenGL context is live:
sqweek/dialog's Linux backend never calls XInitThreads() before
gtk_init(), and GLFW's own X11 backend installs a process-wide Xlib
error handler that collides with GTK's.

Vendor a pinned copy of github.com/sqweek/dialog (New BSD) into
modding/sqweek-dialog/ so it can be patched like every other vendored
source here: sqweek-dialog-patches/01-xinitthreads.patch adds the
missing XInitThreads() call, and hacked-patches/01-use-patched-dialog.patch
adds a go.mod replace directive pointing hacked's own sqweek/dialog
dependency at that local copy. build-hacked.sh now stages both hacked/
and sqweek-dialog/ into a scratch modding/build/ directory and applies
their patch series before compiling, rather than building hacked/ in
place; .gitignore picks up that new scratch directory.

Also expand modding/README.md: number its existing sections and add a
worked "Example: Editing a message" walkthrough covering Static World
Data, the Messages window's clipboard-only text editing, saving the
Mod Location, and the absolute-path requirement for run.sh's mod-folder
argument.
2026-09-26 10:37:01 +02:00
ml c91080aba7 Adding modding folder with a hacked build. 2026-09-26 08:53:08 +02:00
ml cdb960dc71 Adding publishing assets for Github and Sidequest 2026-09-26 06:45:46 +02:00
ml 790b4ffd21 Add screenshots, a quick start and a trademark notice to the README
build / build (push) Successful in 3m8s
2026-09-25 20:25:17 +02:00
ml 135e474a14 Add v0.5.3 screenshots and a video; name captures by version.
build / build (push) Successful in 2m52s
2026-09-21 20:14:51 +02:00
ml 295f4869c1 Sign release APKs with a fixed key from CI secrets
build / build (push) Successful in 2m48s
2026-09-20 17:47:43 +02:00
ml f0a83df667 Replace the logo and icon with QuestShock artwork; add a GitHub release workflow
build / build (push) Successful in 2m14s
2026-09-20 17:23:55 +02:00
ml 9586c58a67 Add a QuestShock startup splash; rename the app and data folder
build / build (push) Successful in 2m35s
2026-09-20 16:49:28 +02:00
ml 5d7564c3b5 Update the docs and add AGENTS.md
build / build (push) Successful in 2m18s
2026-09-20 15:25:33 +02:00
ml 70c43240bc Add a Cheats menu tab with engine cheat buttons and unlimited-resource switches
build / build (push) Successful in 3m42s
The new Cheats tab exposes the engine's own cheat handlers as buttons:
give all items and weapons, toggle physics, next level and previous
level. Physics shows its state through the button highlight. The buttons
need a running game and show a message otherwise.

It also has four independent session-only switches for unlimited health,
energy, ammo and items (grenades and drugs). While a switch is on, its
resource is topped up every frame by qs_cheat_refill() from the new
engine patch 26. The switches are not saved to the config.
2026-09-20 14:59:33 +02:00
ml d8324d409e Add free view and a separate cyberspace action set
build / build (push) Successful in 3m24s
- Free view: hold a mapped input (default: left grip) and that thumbstick
  drives the engine's mouse look, with the stick's normal bindings suspended
  while held. Also available for the right stick, unbound by default.
- Cyberspace actions: eleven thrust/climb/dive/bank/roll actions with their
  own bindings, active only in cyberspace. The normal movement actions are
  inactive there, and capture exclusivity only compares actions that can be
  active together, so one input can be bound in both sets.
- Cyberspace pitch and bank scale with thumbstick deflection instead of
  running at full strength.
- Remove the toggle_freelook action; bind the automap view to the right
  stick click by default.
- Engine patches: 24 exposes whether the player is in cyberspace, 25 lets
  the controller code scale cyberspace pitch and bank.
2026-09-20 07:24:44 +02:00
ml c24b7f15cb Add remappable controller controls with a Controller Options tab
build / build (push) Successful in 2m14s
Add a player_controls config group that maps game actions to controller
inputs, and a Controller Options menu tab to rebind them in-headset.

- 45 actions: movement, click/double-click, jump, fire, and the engine's own
  keyboard hotkeys (posture, save/load, weapon/MFD select, etc.), sent to the
  engine as synthetic key events. Cheat and dead hotkeys are not exposed.
- Press-to-capture rebinding with per-input exclusivity, Reset to Defaults
  (built-in defaults, then questshock.config.default.json), and new defaults:
  left stick moves/strafes, right stick turns.
- Controller profiles for Touch, Vive and generic OpenXR
  (res/controller_profiles/), matched to the active interaction profile at
  runtime, with a fatal dialog if a profile's bindings are rejected at
  startup.
- The menu's thumbstick locomotion is disabled while the Controller Options
  tab is open or a capture is active. A scrollable list with a tap-to-jump
  scroll bar handles the long action list.
- Opening the menu pauses a running game and resumes it on close.
- Reset Position now keeps the player's facing instead of turning the screen
  to the current gaze.
- Play the intro video in the game's language (engine patch 23), falling back
  to English.
- Watchdog and shared-key handling for stuck movement keys, plus key-edge
  logging.
2026-09-20 06:08:50 +02:00
ml e1e6a006d4 Add configurable game/initial screen width and distance, with a bundled first-run config
build / build (push) Successful in 2m41s
- Video Options tab offers width and distance presets independently for
  the Initial Screen (splash/intro/menu) and Game Screen (gameplay)
  profiles, with their preset step/offset configurable via
  questshock.config.json.
- Ships res/questshock.config.default.json as the initial config on
  first run (or whenever questshock.config.json doesn't exist yet),
  refreshed from the APK on every launch without touching a user's own
  saved config.
2026-09-06 09:08:54 +02:00
ml 5fe4cdf7f8 Bump AGP to 8.5.2 and Gradle to 8.7 for 16 KB page-size support
build / build (push) Successful in 2m21s
Every .so this project builds was already ELF-segment-aligned for 16 KB
pages (build-image/Dockerfile's ANDROID_16KB_LDFLAGS, app/build.gradle's
own CMAKE_SHARED_LINKER_FLAGS), but AGP 8.1.1 doesn't align native
libraries' zip entries within the APK itself - that only became automatic
in AGP 8.5.1, which is what Android Studio's "not compatible with 16 KB
devices" warning was actually flagging. No build-image changes needed:
Gradle/AGP are resolved over the network at build time, not baked into
the image.
2026-09-05 16:18:29 +02:00
ml 369555efc7 Making the menu slightly larger 2026-09-05 15:53:15 +02:00
ml 360a1f7e3d Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s
- Left thumbstick snap-turns, right thumbstick smooth-moves, letting the
  player line themselves up with the fixed game quad ("virtual screen")
  without needing physical room-scale space to do it in. Implemented via
  a second OpenXR reference space (a "play space") derived from the real
  LOCAL space by the inverse of the player's transform, so every existing
  quad/hit-testing/rendering call site just needed g_local_space swapped
  for it - no manual coordinate-transform math required elsewhere
- Generalized the ray/quad hit-testing in xr_input.c to a proper
  oriented-plane intersection (needed once quads can rotate); verified it
  degenerates to the old axis-aligned formula when unrotated, so the
  keyboard and non-recentered menu are unaffected
- The menu launcher now re-centers in front of the player, correctly
  oriented to face them, each time it's freshly reopened
- "Reset Position" (Main Menu tab) recenters using the player's current
  real head pose - not a plain reset-to-origin, which only undid
  thumbstick movement and ignored physical drift since the session
  started. The same recenter runs once automatically at startup too, as a
  fallback for when there's no complete saved position to use instead
- Position/orientation persists in questshock.config.json's new
  player_position group (independent of savegames), including new
  numeric-value support in the config file's hand-rolled JSON parser
  (previously string-enums only)
2026-09-05 15:47:54 +02:00
ml c0619a55ab Add a language option to the VR menu, and fix a crash switching to it
build / build (push) Successful in 2m22s
- New "Language" row on the Main Menu tab (English/French/German),
  backed by a new "general" group in questshock.config.json
- Fixes a device-reproduced crash: switching language used to call
  language_dealfunc(), whose render_run()/opanel_redraw() repaint
  assumes the game's own in-game options panel is on screen - crashes
  (SIGSEGV) when triggered from our independent VR overlay instead.
  Now calls language_change() directly, which is all that's actually
  needed to reload resources and refresh the inventory/MFD/side-icon UI
- Fixes a second crash: this data set ships frnstrng.res/gerstrng.res
  but not mfdfrn.res/mfdger.res, and language_change() closes the
  current resource files before discovering the new ones don't exist -
  leaving no valid resource file open and crashing on the next lookup.
  Now checks both files exist before ever switching, refusing the
  change (with an in-headset popup) instead of corrupting resource state
- extract_assets.sh can now also pull mfdfrn.res/mfdger.res out of a
  System Shock: Classic installer (which includes them, unlike Enhanced
  Edition) if one is present in res/assets/ - entirely optional
- Documented all of the above in README.md/CONFIG.md
2026-09-05 08:09:12 +02:00
ml 09280dd410 Overhaul the VR menu UI: header, tabs, live video options, hover/click feedback, i18n, and dragging
build / build (push) Successful in 2m25s
- Header with title, git-tag-derived version, and a live FPS/frame-time/
  CPU/GPU-utilization/free-memory stats ribbon below the tab bar (FPS via
  a new per-frame tracker in xr_session.c; CPU/GPU via the OpenXR
  XR_META_performance_metrics extension, gracefully unavailable on
  runtimes that don't support it)
- Bottom tab bar with a live-editable Video Options tab (renderer/
  resolution), applying changes immediately via qs_config.c
- Hover and press feedback on every menu/keyboard button via a
  StateListDrawable (Android's default ripple never animates on this
  permanently unattached view tree), with a lighter default button color
  for visibility
- i18n for all menu/keyboard chrome text, following the game's own
  language setting (Strings.java)
- The main menu is now draggable via its header, matching the keyboard;
  xr_input.c's drag handling is generalized to per-overlay state instead
  of being keyboard-only
- The keyboard's title bar now matches the menu header's style
2026-09-05 07:02:30 +02:00
ml c4fd4eb3dd Adding support for a configuration file 2026-09-05 05:30:00 +02:00
ml e0a2e1f4bf Fix persistent GL_INVALID_OPERATION spam and resolution-change bugs on Android
build / build (push) Successful in 2m44s
- opengl_end_frame() bound framebuffer 0 on Android, which has no valid
  target there (real presentation goes through the OpenXR swapchain's own
  FBOs) - gl4es reported this every frame. Skip it there.
- render_hack_cameras() re-entered opengl_start_frame()/opengl_end_frame()
  for camera-monitor renders that never touch the GL screen; excluded them
  to match use_opengl()'s existing behavior. Also drains a stray error left
  by xrPollEvent().
- ChangeScreenSize() never called opengl_resize() on Android, freezing the
  3D render target at launch size after an in-game resolution change; and
  xr_get_game_resolution() used the swapchain's fixed size instead of the
  current screen resolution, misplacing the laser-pointer cursor after such
  a change.
- Removes all temporary bisection diagnostics added while tracking down the
  GL_INVALID_OPERATION source.
2026-09-04 16:34:52 +02:00
ml 504ed3e1cb Fix SFX and MIDI music not playing on Android
snd_start_digital() opened two separate SDL audio devices - a raw one
for cutscene/audiolog audio and a second via Mix_OpenAudio() for
SFX/MIDI - but Android's audio backends only ever allow one open
non-capture device, so the second open always failed silently.

- Consolidate onto a single Mix_OpenAudio() device; cutscene/audiolog
  PCM is now mixed into its output via Mix_SetPostMix() instead of a
  dedicated device (16-android-audio-single-device.patch)
- Add SDL_GetError() to the failure log so a future open failure is
  actually diagnosable (15-android-audio-error-detail.patch)
- Make NativeMidiInit() report failure on platforms with no real
  implementation (Android included), instead of silently claiming
  success without opening anything (17-android-native-midi-unsupported.patch)
- Strengthen the OpenSL ES driver hint to SDL_HINT_OVERRIDE and log the
  active audio driver as a standing diagnostic (05-android-audio-driver.patch)
2026-08-30 07:09:16 +02:00
ml d6e438a541 Fix 3D texture corruption and stuck/dead-key input bugs
build / build (push) Successful in 2m13s
Stale GL_UNPACK state in gl4es and a missing pre-release GPU sync
caused diagonal-banded corruption in the 3D view. Synthetic key
press/release must track the real controller button or touch down/up
edges, not fire together in one call - fixes a typed movement key
sticking forever, then a follow-up that had silently broken A-button
jump and on-screen keyboard arrow keys. Also adds force-rebuild.sh for
the known stageEngine stale-cache issue.
2026-08-29 19:26:53 +02:00
matthias a2fb95e57b Add Windows cross-compilation via MinGW, and fix Docker build permission
build / build (push) Successful in 3m5s
issues on vboxsf-mounted checkouts

New `make dist-win`/`package-win` targets (folded into `dist`/`package`
alongside the renamed `dist-linux`/`package-linux`) cross-compile
systemshock.exe via MinGW, using prebuilt SDL2/SDL2_mixer/GLEW/
fluidsynth-lite baked into the build-image - no Windows machine or Wine
needed to build it, confirmed working and playable on a real Windows
machine. dist-win/ ships DLLs flat alongside the exe plus a new
res/run.bat launcher, packaged into a .zip the same way dist/ becomes a
.tar.gz.

Also fixes three build-image bugs hit while testing on a VirtualBox
vboxsf-mounted checkout: build-engine.sh/docker-entrypoint.sh losing
their execute bit (chmod +x on restrictive source perms), the container
user missing access to /workspace's supplementary vboxsf group, and
cp -a failing on symlink/hard-link creation (vboxsf doesn't support
either) - now falls back to dereferencing copies when detected. Also
adds Docker/zip/etc. prerequisites to the README for both the desktop
and Quest builds.
2026-08-16 06:27:31 +02:00
ml 581af95e7b Drive the engine's mouse cursor from the VR aim ray on the game quad
build / build (push) Successful in 1m44s
The game quad's laser beam was purely visual - aiming at the game's own
UI (inventory, menus, dialogs) had no way to interact with it. Add a
real mouse pointer: absolute cursor position plus a single left click,
mirroring a plain point-and-click mouse (no mouselook, no right-click,
no drag).

New xr_mouse.c/h bridges the aim-ray hit-test to the vendored engine's
own public mouse API - mouse_put_xy() for position, a synthetic
SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) via SDL_PushEvent for clicks,
the same technique nativeSendPrintableChar() already uses for
synthetic keyboard input. No engine patch needed. Deliberately bypasses
SDLActivity.onNativeMouse()/SDL_MOUSEMOTION/SetMouseXY(), whose
physical-window-size scaling is unreliable in this headless immersive
build.

xr_input_try_game_quad() now reports the hit u,v; a new
g_game_touch_active[] tracks a held click on the game quad so it
survives the ray straying onto the keyboard/menu before release,
gating those overlays' claims the same way pending overlay touches
already do.
2026-08-14 08:36:09 +02:00
ml 824b4a2506 Give the game quad the same laser-beam pointer as the overlays
Aiming looked and behaved differently depending on whether the
keyboard/menu was open: xr_input_try_overlay() claimed a hand's ray for
its overlay whenever the overlay was merely visible, regardless of
whether the ray was actually pointed at it - so with the keyboard open,
aiming at the main game screen still showed a beam terminating at the
keyboard's fixed depth, while closing the keyboard swapped in the old
flat cross-shaped reticle at whatever the ray was really pointing at.

Only claim an overlay when the ray is actually relevant this frame - a
real hit, or a touch/drag begun on a previous frame still pending
release - so a visible-but-unaimed-at overlay now falls through to the
next-priority target. Give the game quad its own hit-test
(xr_input_try_game_quad()) and the same billboarded laser-beam
treatment as the overlays instead of the old flat reticle, so aiming
looks and behaves identically everywhere - overlay open or not. The
old reticle GL program/shaders are now fully dead and removed. No
click/touch dispatch is added to the game quad itself; that's future
work.
2026-08-14 07:59:09 +02:00
ml 920842a465 Show a laser-beam pointer while aiming at the menu/keyboard
build / build (push) Successful in 1m40s
The flat cross-shaped reticle used while aiming at the game quad is
drawn directly into the game quad's own texture, so it only ever made
sense there - while the menu launcher or keyboard was open, there was
no visual feedback at all until the ray was precisely on target,
making both panels hard to aim at.

Add a second, thin XrCompositionLayerQuad per hand: a billboarded
"laser beam" ribbon from the controller to wherever that hand's ray
currently crosses the plane of whichever overlay (keyboard, then menu)
claims it that frame, oriented via a view-space head pose so it reads
as a line from the viewer's eye regardless of angle, colored per hand
(cyan left, amber right) and clamped to 2m so grazing angles don't
produce an absurdly long beam. Built from data xr_input.c's existing
per-hand hit-testing already computes; the game-quad reticle itself is
untouched.
2026-08-14 07:30:13 +02:00
ml 3be310ade6 Split the keyboard onto its own translucent OpenXR quad
build / build (push) Successful in 1m52s
Expand the on-screen keyboard to a full US layout (letters, digits,
punctuation, Shift layer, Tab, arrows, F1-F12, one-shot Ctrl/Alt
modifiers), make it a persistent, movable, closeable panel via a
title-bar drag handle, and lower its opacity.

Previously the keyboard was just a second panel swapped into the same
quad as the menu launcher, which made the launcher translucent too and
made the keyboard and menu mutually exclusive. Extract the shared
swapchain/JNI/touch-dispatch plumbing into two generic, reusable
modules - xr_swapchain.c (swapchain + per-image FBO setup) and
xr_overlay.c (an off-screen Android View rendered into its own OpenXR
quad, hit-tested via a laser pointer) - and make the menu launcher and
keyboard two independent XrOverlay instances instead of one. The
controller's menu button now only opens/closes the launcher; the
launcher's "Keyboard" button only opens the keyboard - so the keyboard
can stay up while picking something from the menu, and only the
keyboard's own Close button hides it.

xr_menu.c/.h are gone, fully absorbed into xr_overlay.c. On the Java
side, OverlayPanel.java carries the shared off-screen-render/touch/
cursor plumbing that MenuOverlay and the new KeyboardOverlay both
subclass.
2026-08-14 06:43:44 +02:00
ml 2d8fb49bf5 Add hand-built on-screen keyboard to the OpenXR menu quad
build / build (push) Successful in 1m45s
MenuOverlay's key grid forwards every press to the game as real input:
non-printable keys (Esc/Enter/Backspace) as an SDLActivity.onNativeKeyDown()/
onNativeKeyUp() pair, and printable keys (letters, Space) as a synthesized
SDL_TEXTINPUT event pushed directly from native code, since the engine's
pump_events() only picks up printable ASCII from that event type, not from
SDL_KEYDOWN. Confirmed on-device, including that Space now correctly skips
the intro cutscene alongside Esc/Enter.
2026-08-05 08:11:16 +02:00
ml 1be0bebda2 Split the menu quad back onto its own independent OpenXR swapchain
build / build (push) Successful in 1m43s
Replaces the single shared, side-by-side swapchain (game + menu content
packed into one image, needing viewport/scissor juggling to keep the
menu's own glClear from bleeding into the game's region) with two fully
independent XrSwapchainState instances, each sized to exactly its own
content and acquired/released on its own within the same
xrBeginFrame/xrEndFrame pair - an ordinary multi-layer OpenXR setup.

The shared-swapchain design was originally adopted to work around what
looked like a Horizon OS compositor limitation with independent
swapchains, but that was diagnosed before the real root cause of the
"menu shows the game's content" bug was found (gl4es's fpe.c
unconditionally substituting its own shader onto the menu's draw call -
see README's Debugging notes). Since the actual fix routes the menu's
blit through a real, non-gl4es glBlitFramebuffer() call - orthogonal to
how many swapchains exist - splitting back onto two swapchains works
fine and removes the GL_SCISSOR_TEST workaround and sub-rectangle
offset math entirely. Confirmed on-device: both quads render correctly,
hit-testing and 90 FPS unaffected.

Also adds a visible cursor to the menu quad itself: xr_input.c now
tracks whichever hand's aim ray hits the menu each frame and forwards it
to a new MenuOverlay.nativeUpdateCursor(), which draws a small dot at
that position (composited through the same Bitmap the menu's own
content already goes through) - previously there was no visual feedback
at all showing where you were pointing before pulling the trigger.
2026-08-02 08:01:14 +02:00
ml 2b52b818e8 Added a make android-studio target that wraps ./run-image.sh bash build-image/prepare-android-project.sh --host-paths, and updated the README (Layout's Makefile-target list, "5.2. Building natively in Android Studio", and "5.3. Testing cycles") to reference it — including a note that it must be re-run after make clean specifically, since that's what just bit you. Run make android-studio now to fix the build.
build / build (push) Successful in 1m42s
2026-08-02 06:46:07 +02:00
ml d2dc58391b Add controller input and a laser-pointer-driven menu quad
build / build (push) Successful in 2m26s
- xr_input.c/h: an OpenXR action set (aim pose, trigger/select click, a
  menu-toggle button) plus ray/quad hit-testing, and a visible
  laser-pointer line drawn from the controller toward the hit point.
- xr_menu.c/h + MenuOverlay.java: a second composition-layer quad,
  toggled by a controller button, showing an off-screen, never-attached
  Android View tree (one "Keyboard" button so far) driven by synthetic
  touch events computed from the laser ray's hit UV.
- Migrate gl4es from a prebuilt binary baked into the Docker build-image
  to a vendored source snapshot (android/gl4es-src/) compiled from
  source at APK build time via CMake add_subdirectory(), patched through
  a new android/gl4es-patches/ (mirrors the existing engine-patches/
  pattern). This was needed to track down and fix a bug hit while
  building the menu: gl4es's fixed-pipeline emulation (fpe.c) was
  unconditionally substituting its own shader onto the menu's draw call
  (fpe_ReleventState() always sets alphafunc to a nonzero sentinel, so
  its fpe_IsEmpty() check could never see the state as empty), making
  the menu quad show the game's own rendering instead of its own
  content. Fixed by routing the menu's blit through a real
  glBlitFramebuffer() call instead of a shader-based draw, which gl4es's
  fpe.c has nothing to intercept - documented in README.md's new
  "Debugging notes" section.
- Renumber android/engine-patches/ to close the gap left by removing an
  unrelated diagnostic-only patch, and strip investigation-journal
  comments and dead diagnostic code (temporary tracing, env-var probes)
  left over from finding the bug above.
- Restructure README.md into numbered sections, document every
  engine/gl4es patch, add Android Studio dev/testing-cycle instructions,
  and generalize Quest-specific wording to any OpenXR headset. Update
  NOTICE.txt to match (gl4es is now vendored/patched source, not a
  prebuilt binary; add the OpenXR-SDK loader).
2026-08-02 06:27:49 +02:00
ml 2300d081c3 Add OpenXR bring-up: render the game as a floating quad in an immersive Quest session
build / build (push) Successful in 2m12s
- New android/app/src/main/cpp/xr_session.c (+.h): owns the OpenXR
  instance/session/local-space/swapchain and the per-frame
  xrWaitFrame/xrBeginFrame/xrEndFrame loop, submitting the game's
  existing flat render as a single head-tracked XrCompositionLayerQuad.
  No stereo rendering or controller input yet - that's steps B/C/D of
  the plan.
- 11-android-openxr-cmake.patch: links the OpenXR loader as a build
  dependency, staged into jniLibs the same way as gl4es/SDL2.
  build-image/Dockerfile and prepare-android-project.sh build and stage
  it.
- 12-android-openxr-present.patch: redirects OpenGL.cc's final present
  step (opengl_swap_and_restore / SDLDraw's software path) into the XR
  swapchain FBO instead of the window, on Android only. Two real gl4es
  bugs had to be worked around to get pixels on screen at all:
    - gl4es's own glBindFramebuffer errors on an FBO id it didn't create
      itself, even though the real driver-level bind succeeds - fixed by
      creating the swapchain FBO container via gl4es's own
      glGenFramebuffers/glBindFramebuffer, and only using the real
      (dlsym'd) driver call for attaching OpenXR's foreign swapchain
      texture, which gl4es's own attach can't handle.
    - gl4es's glBegin/glVertexAttrib/glVertex3f immediate-mode emulation
      only captures a fresh per-vertex value for attribute 0 (position,
      driven directly by glVertex3f) - custom attributes like this
      shader's texcoords/light are GLES2's *constant*-attribute API and
      applied once for the whole draw, not per vertex, silently
      collapsing the UI-overlay quad to a single sampled texel. Fixed by
      switching that one draw call to real vertex arrays
      (glVertexAttribPointer/glDrawArrays).
  Also flips the V texcoord to match SDL's top-down row order against
  GL's texture convention, and reuses a persistent texture object
  instead of a fresh gen/upload/delete every frame.
- AndroidManifest.xml: declares the immersive-HMD intent category and
  focus-aware metadata, drops the 2D-panel layout hint.
- README: documents the new OpenXR immersive mode.
2026-07-25 07:25:27 +02:00
ml 0f59898bb6 - AndroidManifest.xml: add android:screenOrientation="landscape" and a
build / build (push) Successful in 2m15s
<layout> defaultWidth/defaultHeight/minWidth/minHeight/gravity hint -
  Quest's Home shell otherwise defaults a freshly-launched 2D panel to a
  portrait shape, cropping the game's 4:3 640x480 content down to a
  sliver.
- The actual root cause of the remaining crop (game rendering into one
  corner of an otherwise correctly-sized panel), found via on-device
  logcat and the new diagnostics below rather than guesswork:
  ChangeScreenSize() (engine/src/MacSrc/ShockBitmap.c) calls
  SDL_SetWindowSize() whenever the game sets its video mode. That's a
  desktop-only operation in effect - Android has no SetWindowSize driver
  hook, so SDL's generic layer instead overwrites its own cached window
  size to the game's internal resolution (640x480) and synthesizes a
  resize event from that, desyncing SDL's notion of the window size from
  the real, unchanged Android surface (e.g. 1600x1200). Both SDL's own
  renderer viewport and the engine's custom GL viewport then scale
  against that corrupted cached size. 10-android-no-window-resize.patch
  skips the desktop-only SDL_SetWindowFullscreen/SetWindowSize/
  SetWindowPosition calls on Android, keeping the legitimate
  SDL_RenderSetLogicalSize/offscreen-bitmap setup.
- 06-android-resize-event.patch: also react to SDL_WINDOWEVENT_RESIZED
  in the engine's event loop, not just SIZE_CHANGED - Android's SDL video
  backend never sends SIZE_CHANGED for surface-driven resizes, only
  RESIZED, an independent gap worth closing regardless of the bug above.
- 08/09-android-logcat-*.patch: route the engine's existing log.c output
  (previously plain fprintf(stderr,...), never actually captured by
  logcat on this build) through __android_log_vprint instead, so every
  existing INFO/DEBUG/WARN/ERROR call site becomes visible for on-device
  debugging. This is what made the diagnostic below (and everything
  since) observable at all.
- 07-android-size-diagnostics.patch: one-time startup log comparing
  SDL_GetWindowSize/SDL_GL_GetDrawableSize/SDL_GetRendererOutputSize -
  the evidence that actually pinned down the SDL_SetWindowSize bug above.
- QuestShockActivity.java: add a diagnostic onSizeChanged() log on
  GameSurface, used to rule out a later Android-side panel relayout as
  the cause before finding the real one.
2026-07-24 06:31:18 +02:00
ml 4e47e0a989 Fix missing-assets detection race, 16 KB page alignment, and Android audio backend
build / build (push) Successful in 2m11s
- QuestShockActivity now actually blocks the native engine from starting
  when game data is missing, closing three gaps found via on-device
  testing: super.onCreate() must run unconditionally first (Android
  throws SuperNotCalledException otherwise); SDLActivity.mBrokenLibraries
  is now set provisionally before the storage-permission check, since
  onWindowFocusChanged() closing the permission dialog could otherwise
  start the engine before the async onRequestPermissionsResult() callback
  ran; and a new GameSurface (SDLSurface subclass) closes the actual gap
  that let the init_popups NULL-deref crash through even with
  mBrokenLibraries set - SDLSurface.surfaceChanged() starts the native
  thread directly without ever checking that flag.
- Force Android to use SDL2's openslES audio backend instead of AAudio
  (android/engine-patches/05-android-audio-driver.patch): AAudio only
  allows one open playback device at a time, but the engine opens two
  (cutscene audio via SDL_OpenAudioDevice, SFX/MIDI via Mix_OpenAudio),
  hitting an assertion failure on real hardware.
- Add 16 KB ELF page-size alignment (-Wl,-z,max-page-size=16384) to every
  Android shared library - the four prebuilts (SDL2, SDL2_mixer,
  fluidsynth-lite, gl4es, in build-image/Dockerfile) and the engine's own
  libmain.so (build.gradle) - matching Google's Play Store requirement
  for Android 15+ and clearing Android Studio's compatibility warning.
- Add a stageEngine Gradle task that automatically re-stages the patched
  engine/ copy and prebuilt libraries before any Android Studio build
  (hooked into preBuild, with proper up-to-date checking), so source/
  patch changes can't silently go stale in the build/android-engine
  scratch copy - previously a manual, easy-to-forget step. Skips
  automatically inside the build-image container so make apk/CI are
  unaffected.
2026-07-23 19:20:22 +02:00
ml ac640762d7 Fix Quest launch crashes, version the APK like the tarball, and support native builds in Android Studio
build / build (push) Successful in 2m13s
- patchelf gl4es's embedded SONAME to libGL.so so AGP's jniLibs packaging
  (which drops any file not literally named "*.so") and the dynamic
  linker's NEEDED-entry resolution (by embedded SONAME, not filename)
  finally agree - fixes the "library \"libGL.so.1\" not found" crash seen
  on real Quest hardware.
- QuestShockActivity now checks that res/data and res/sound exist before
  starting the native engine, showing an explanatory dialog instead of
  crashing on init_popups' unchecked NULL resource load when a fresh
  install has no game data copied in yet.
- dist/questshock-<version>-android-arm64.apk is now versioned from the
  same git-tag-or-dev-placeholder scheme as the desktop tarball
  (build-image/version.sh, shared by both via the Makefile and
  build-apk.sh).
- build-image/prepare-android-project.sh (prep logic extracted out of
  build-apk.sh) can now stage android/ for a native build directly in
  Android Studio (--host-paths), exporting the prebuilt SDL2/SDL2_mixer/
  fluidsynth-lite/gl4es libraries and writing host-resolvable paths,
  instead of only ever building inside the Docker image.
- Corrected GET_ASSETS.txt/GET_ASSETS_QUEST.txt, which wrongly described
  merging res/pc/hd and res/pc/cdrom trees out of the raw installer's
  sshock.kpf - an already-installed copy's res/data res/sound can just
  be copied directly, with extract_assets.sh only needed from the raw
  installer.
2026-07-23 06:42:45 +02:00
ml 3da137da09 rename Shockolate tar ball 2026-07-20 13:51:10 +02:00
ml 3034703994 Adding publishing of the APK
build / build (push) Successful in 1m37s
2026-07-20 13:16:33 +02:00
ml 66f02a0c61 The Android/Quest CMakeLists.txt patch previously reimplemented desktop
build / build (push) Successful in 1m8s
immediate-mode GL (glBegin/glVertex3f/glEnd and friends) from scratch on
top of GLES, since GLES has none of it. That code compiled but was never
actually exercised - no on-screen verification was possible without real
Quest hardware - making it the biggest unverified risk in the port.

Replace it with GL4ES (MIT-licensed, prebuilt into the build image),
a mature desktop-GL-on-GLES translation library used by other
Quest/Android game ports for exactly this problem. This cuts
04-android-opengl-es-render.patch by half, deleting the hand-written
vertex-accumulation emulation entirely; alpha test, point sprites, and
point size stay on their existing GLES-native fixes rather than trusting
GL4ES's shakier custom-shader interop for those.

Bump build-image/VERSION to 3 - a fresh image with GL4ES cross-compiled
for arm64-v8a was built and make apk verified successfully against it.
2026-07-20 12:52:34 +02:00
ml 9e2c9ffbe6 Adding first APK build 2026-07-20 09:45:08 +02:00
ml 8712269b51 Adding a logo and NodeJS to the build image
build / build (push) Successful in 42s
2026-07-19 16:50:36 +02:00
1227 changed files with 215404 additions and 112 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+61 -12
View File
@@ -1,14 +1,16 @@
name: build
# Builds a versioned Linux release tarball (see `make package`) on every
# push/PR, plus on-demand via the Gitea "Run workflow" button. Runs
# Builds versioned Linux and Windows release packages (see `make
# package`, which builds both - a .tar.gz and a .zip) on every push/PR,
# plus on-demand via the Gitea "Run workflow" button. Runs
# inside the build-image (see ../../build-image/Dockerfile, built/pushed
# via ../../build-image.sh and ../../upload-image.sh), which bundles
# every dependency engine/ needs to compile - no network access needed
# at job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine` prerequisite
# compiles directly instead of trying to docker-run it again, which
# wouldn't work here (no nested docker).
# every dependency engine/ needs to compile (for both platforms,
# including the Windows cross-toolchain) - no network access needed at
# job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine`/`engine-win`
# prerequisites compile directly instead of trying to docker-run it
# again, which wouldn't work here (no nested docker).
on:
push:
pull_request:
@@ -45,26 +47,71 @@ jobs:
command -v cmake >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: cmake not in PATH" >&2; exit 1; }
[ -n "${QUESTSHOCK_BUILD_IMAGE:-}" ] || { echo "PREFLIGHT FAIL: QUESTSHOCK_BUILD_IMAGE not set - not running inside the questshock build-image?" >&2; exit 1; }
[ -d /opt/prebuilt/built_sdl ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/built_sdl missing" >&2; exit 1; }
# gl4es itself is no longer prebuilt here (compiled from source at
# APK-build time instead - see build-image/Dockerfile's gl4es
# comment) - openxr was added to this image in the same
# Android/Quest layer, so it's an equally good marker of that.
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
[ -d /opt/prebuilt/win/sdl2 ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/win/sdl2 missing - runner is using a build-image older than the Windows cross-build layer" >&2; exit 1; }
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: x86_64-w64-mingw32-gcc not in PATH" >&2; exit 1; }
command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not in PATH (needed by make package-win)" >&2; exit 1; }
- name: Build package
run: make package
- name: Upload build artifact
- name: Upload build artifacts
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
uses: actions/upload-artifact@v3
with:
name: questshock
path: dist/questshock-*-linux-*.tar.gz
name: shockolate
path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64
# (see README section 4.3). Without it the APK is a debug build.
- name: Prepare release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
echo "No signing key: the APK will be a debug build"
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
- name: Build Quest APK
env:
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
# this runs build-apk.sh directly instead of trying to docker-run
# the image again - same reasoning as `make package` above.
# Unlike the fully offline engine/package build, this needs
# network access for Gradle/AGP's own dependency resolution - a
# scoped, accepted exception (see build-image/Dockerfile).
run: make apk
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: questshock-quest-apk
path: dist/questshock-*-android-*.apk
- name: Publish to dl.ladkau.de
# Uploads the tarball over SFTP instead of using
# Uploads the tarball, zip, and APK over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
TARBALL="$(ls dist/questshock-*-linux-*.tar.gz)"
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
ZIP="$(ls dist/shockolate-*-windows-*.zip)"
APK="$(ls dist/questshock-*-android-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
@@ -73,4 +120,6 @@ jobs:
uploader@dl.ladkau.de <<EOF
-mkdir files/questshock
put $TARBALL files/questshock/$(basename "$TARBALL")
put $ZIP files/questshock/$(basename "$ZIP")
put $APK files/questshock/$(basename "$APK")
EOF
+145
View File
@@ -0,0 +1,145 @@
name: release
# GitHub's build. The Gitea build is .gitea/workflows/build.yml.
#
# Pushing a tag vX.Y.Z builds the Linux and Windows packages and the Quest APK
# inside the build-image and attaches them to a GitHub release. A manual run
# builds the same files and keeps them as a workflow artifact without
# releasing them.
#
# The build-image (build-image/Dockerfile) is published to GHCR, tagged with a
# hash of the files that define it, and only built when that tag does not
# exist yet.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
name: ${{ steps.name.outputs.name }}
steps:
- uses: actions/checkout@v4
- name: Name the image after the files that define it
id: name
run: |
set -euo pipefail
hash="$(cat build-image/Dockerfile build-image/docker-entrypoint.sh build-image/build-engine.sh | sha256sum | cut -c1-16)"
echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/questshock-builder:${hash}" >> "$GITHUB_OUTPUT"
- name: Build and push the image if it does not exist yet
env:
IMAGE: ${{ steps.name.outputs.name }}
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "$REGISTRY_PASSWORD" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
echo "$IMAGE already exists"
exit 0
fi
# The image is large and the runner's disk is small.
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
# The label links the package to this repository, so the build job
# can pull it with the workflow's token.
docker build -f build-image/Dockerfile \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
-t "$IMAGE" .
docker push "$IMAGE"
build:
needs: image
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
defaults:
run:
shell: bash
# The job runs inside the build-image, so `make` compiles directly instead
# of starting the image itself (QUESTSHOCK_BUILD_IMAGE is set in it).
container:
image: ${{ needs.image.outputs.name }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
# The version comes from `git describe --tags`.
fetch-depth: 0
- name: Build the Linux and Windows packages
run: make package
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64; see
# README section 4.3. A tag build must be signed with it.
- name: Prepare release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
echo "::error::ANDROID_KEYSTORE_BASE64 is not set: a release APK must be signed"
exit 1
fi
echo "No signing key: the APK will be a debug build"
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
# Needs network access for Gradle's own dependency resolution.
- name: Build the Quest APK
env:
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: make apk
- uses: actions/upload-artifact@v4
with:
name: questshock
path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
dist/questshock-*-android-*.apk
release:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
name: questshock
path: dist
- name: Create the release, or update its files if it exists
run: |
set -euo pipefail
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes \
--notes "Sideload the APK onto a Meta Quest (see README section 5.1). The game data is not included: you need your own copy of System Shock: Enhanced Edition."
fi
+29 -1
View File
@@ -1,15 +1,24 @@
# Android studio project files
/android/.idea
# Game assets are not included in the repo
/res/assets/setup_system_shock_enhanced*
/res/assets/setup_system_shock_classic*
/res/assets/*.zip
/res/assets/ss_ee/
# Build output
/dist/
/dist-win/
/build/
# Engine build artifacts (engine/ is committed as a source snapshot; these
# are generated by run-image.sh / build-image/build-engine.sh)
# are generated by run-image.sh / build-image/build-engine.sh /
# build-image/build-engine-win.sh - the Windows cross-build's own scratch
# copy of engine/ lives under /build/win-engine/, already covered above)
/engine/build_ext/
/engine/.build-output/
/engine/.build-output-win/
/engine/CMakeCache.txt
/engine/CMakeFiles/
/engine/cmake_install.cmake
@@ -17,5 +26,24 @@
/engine/systemshock
/engine/src/Libraries/CMakeFiles/
# Android build artifacts (generated by build-image/build-apk.sh - see
# android/engine-patches/ for why engine/ itself is never touched)
/android/engine.properties
/android/app/src/main/assets/
/android/app/src/main/jniLibs/
/android/app/build/
/android/.gradle/
/android/app/.cxx/
/android/local.properties
# Local registry credentials (see registry.env.example)
/registry.env
# Signing keys
*.jks
*.keystore
# modding/hacked build output and scratch, patched source copies (see
# modding/README.md)
/modding/dist/
/modding/build/
+408
View File
@@ -0,0 +1,408 @@
# AGENTS.md
Guidance for AI coding agents (and new contributors) working on Questshock.
README.md is the user-facing build/usage guide and CONFIG.md documents the
configuration file; this file covers how the code is organised and the rules to
follow when changing it.
## 1. What this project is
Questshock plays the 1994 game System Shock on a standalone VR headset (Meta
Quest 2 is the tested device). It wraps [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original engine, in an Android app that:
- runs the unmodified 2D engine (software or OpenGL renderer) and shows its
frame on a head-tracked virtual screen (an OpenXR quad layer);
- shows a menu and an on-screen keyboard as separate OpenXR quads, operated by
a laser pointer from either controller;
- maps controller inputs to game actions through a configurable binding layer;
- persists settings in `/sdcard/QuestShock/questshock.config.json`.
A desktop (Linux/Windows) build of the same engine exists for development
without a headset; it has none of the Android/OpenXR code.
## 2. Hard rules
These come from the maintainer. Follow them without being asked again.
1. **Never commit.** Do not run `git commit` (or amend, rebase, push), even if
asked to commit. Draft the commit message and show it; the maintainer runs
the commit. Mention any new untracked file that has to be `git add`ed
(typically a new patch under `android/engine-patches/` or
`engine-patches/`).
2. **Do not build.** Do not run `make apk`, `./gradlew`, or `make android-studio`
to check that a change compiles. The maintainer deploys from Android Studio.
`make apk` and `make android-studio` overwrite `android/engine.properties`
with container-only paths and break an open Android Studio project. What you
can do is dry-run patches (section 6) and run static consistency checks
(section 13). Say plainly in your report that the change was not built or
tested.
3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored
snapshots. Every change to them is a numbered patch under
`android/engine-patches/` or `android/gl4es-patches/` (Quest-specific
fixes) or `engine-patches/` (general fixes, applied to the desktop
Linux/Windows builds too - README section 4.4), applied to a scratch
copy at build time. (`engine/` is GPLv3, see README section 6.)
4. **Patch numbers stay contiguous** (`01`, `02`, ... with no gap). When you
insert or delete a patch, renumber the rest and update every reference to
the numbers (README patch reference, code comments).
5. **Comments and documentation describe the current state and why it has to be
that way.** No history or narrative: no "used to", "originally", "no
longer", "now", "this was fixed", "confirmed on device", incident stories or
dropped alternatives. History belongs in commit messages. A comment that
would read oddly to someone who never saw an earlier version should be
rewritten or cut. Default to one or two lines. (README section 5.5, the
debugging notes, is the one deliberate exception.)
6. **Standalone headset only, OpenXR first.** No feature may need a host PC
(no PCVR, tethering or streaming). Prefer OpenXR APIs over vendor-specific
ones (Meta's Oculus Mobile SDK and the like) so the port can run on other
Android OpenXR headsets. Flag a request that would break either rule
instead of quietly doing it.
7. **Do not bump `build-image/VERSION`** just because `build-image/Dockerfile`
changed. Bump it only once a build with the current number has succeeded
end to end (for example a CI run). Ask if unsure.
8. **Keep docs in sync with behaviour.** A user-visible change updates
README.md and/or CONFIG.md in the same change; a new or renumbered engine
patch updates the README patch reference (section 5.4).
## 3. Repository layout
| Path | What it is |
|---|---|
| `engine/` | Vendored Shockolate source. Read-only for us. |
| `android/engine-patches/` | Numbered `patch -p1` files applied to a scratch copy of `engine/`. |
| `engine-patches/` | Same, but general fixes applied to the desktop Linux/Windows builds too (README section 4.4), not Quest-specific ones. |
| `android/gl4es-src/`, `android/gl4es-patches/` | Vendored GL4ES (desktop GL to GLES) and its patches. |
| `android/app/src/main/cpp/` | First-party native code (section 4). |
| `android/app/src/main/java/de/ladkau/questshock/` | First-party Java (section 4). `org/libsdl/app/` is vendored SDL2 glue (zlib). |
| `android/app/build.gradle` | Gradle project; the `stageEngine` task stages the patched engine. |
| `android/force-rebuild.sh` | Stops Gradle daemons and wipes staged/build output for a fully fresh native build. |
| `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). |
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `splash.png` (startup splash), `logo.png` (1024x1024 logo, master of the launcher icons in `android/app/src/main/res/mipmap-*/`), `logo_small.png` (the README header), `screenshots/` (headset captures and videos named `questshock_v<version>_<nnn>.<ext>`, numbered per version), `store/` (store-listing artwork, generated from `logo.png`/`splash.png`/screenshots, not used by the app itself), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
| `.gitea/workflows/build.yml`, `.github/workflows/release.yml` | CI. Gitea is the primary repository and builds on every push; GitHub is a mirror whose workflow builds and publishes a release when a `vX.Y.Z` tag is pushed. Each platform has its own file. |
| `modding/` | Separate subproject: System Shock modding tools (InkyBlackness `hacked`, `ss-specs`), unrelated to the VR app. Own `README.md`, `Makefile` and Docker build-image; see that README rather than this file. |
| `Makefile` | `engine`, `dist`, `package`, `apk`, `android-studio`, `clean`, ... (see README section 2). |
| `README.md`, `CONFIG.md`, `NOTICE.txt` | User docs, config reference, license notice. |
`build/`, `dist/`, `dist-win/` and `res/assets/ss_ee/` are generated or
proprietary and git-ignored. `registry.env` holds local registry settings; do
not print it or commit it.
## 4. Android app architecture
### 4.1 Native modules (`android/app/src/main/cpp/`)
| File | Role |
|---|---|
| `xr_session.c/.h` | OpenXR instance and session lifecycle, frame loop, composition layers (game quad, menu, keyboard, laser beams). |
| `xr_swapchain.c/.h` | Swapchain creation, acquire and release, per-image FBOs. |
| `xr_overlay.c/.h` | Generic "off-screen Android `View` rendered into an OpenXR quad" module, hit-tested by a laser pointer. The menu and the keyboard are two instances. |
| `xr_input.c/.h` | Controller input: OpenXR actions, raw-input layer, action bindings, capture-mode rebinding, key injection into the engine, free view, cyberspace scaling, snap-turn/smooth-move of the player's position, pointer rays. The largest file. |
| `xr_mouse.c/.h` | Injects mouse motion and clicks into the engine. |
| `qs_config.c/.h` | Configuration: JSON parse, apply, write; screen profiles; menu-driven requests; cheats; menu-triggered pause. |
| `qs_splash.c/.h` | Startup splash: `qs_splash_show()` fades the image (`res/splash.png`, decoded by Java and passed in through `nativeSetSplashImage`) in and out on the game screen. Called from engine patch 27. |
| `questshock_native.c` | JNI exports and small native helpers (`nativeChdir`, `nativeSendPrintableChar`, ...). |
### 4.2 Java classes
| Class | Role |
|---|---|
| `QuestShockActivity` | `SDLActivity` subclass. Creates `/sdcard/QuestShock/`, extracts bundled files, blocks engine start until the game data exists. |
| `OverlayPanel` | Base for the off-screen panels: builds the view tree, renders it to a texture, turns ray hits into touch events. |
| `MenuOverlay` | The menu: Main Menu, Video Options, Controller Options and Cheats tabs. Singleton. |
| `KeyboardOverlay` | The on-screen keyboard. |
| `Strings` | All menu and keyboard text in English, French and German. |
### 4.3 How the pieces fit
- The engine's present path is redirected (engine patch 11) so that
`SDLDraw()` submits into the OpenXR game-quad swapchain. The per-frame
entry point of `xr_input.c` is `xr_input_sync_and_draw()`, called from the
frame path in `xr_session.c` on the engine's own thread.
- The engine's own log output goes to logcat (patch 08); Java and native
first-party code log under the tag `QuestShock`.
- Bindings, config and menu state are owned natively. Java only displays and
requests.
- `gScreenWide`/`gScreenHigh` are only set by the engine's
`ChangeScreenSize()`, so they are 0 until its first mode change. Code that
runs during startup (such as `qs_splash_show()`) takes the screen size from
`xr_get_game_swapchain_size()` instead.
### 4.4 Threads and cross-thread requests
- Engine state (player struct, game loop, cheats, config apply) must only be
touched on the engine thread.
- Menu code calls native functions through JNI. Anything that changes engine
state must be enqueued, not executed inline: the JNI export calls a
`qs_config_request_*()` function, which pushes an SDL custom event
(`SDL_RegisterEvents`); `qs_config_handle_event()` handles it on the engine
thread (it is called from `pump_events()`, engine patch 21). To add a new
request, add an event type, the `qs_config_request_*()` enqueue function and
a branch in `qs_config_handle_event()`.
- Getters that Java calls (`qs_config_cheat_unlimited_mask()`, binding-name
getters, ...) must be plain scalar or string reads that are safe from any
thread.
- Per-frame work that must run on the engine thread (such as
`qs_config_frame_tick()`) is called from `xr_input.c`.
- JNI symbol names must match the Java `native` declarations exactly
(`Java_de_ladkau_questshock_<Class>_<method>`). A mismatch only fails at
runtime, so check it (section 13).
### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`)
The view tree is built and drawn from native code and is never attached to a
window. Consequences that shape the UI code:
- Only `ACTION_DOWN`/`ACTION_UP` touches arrive (the laser trigger). There is no
drag, fling or multi-touch. Scrolling is done by a self-drawn scroll bar
(`ScrollBarView` in `MenuOverlay`), not by the framework.
- `View.post()` never runs, so anything that defers work through it does not
work. `ProgressBar` and `SeekBar` are unusable for the same reason.
- Hover and hit-testing use `OverlayPanel.absoluteBounds()`, which subtracts
ancestor scroll offsets.
- Toggling a child's visibility needs an explicit re-measure and re-layout
(see `relayoutAndRedraw()`).
- All user-visible text goes through `Strings.get(key, language)` with an
English, French and German entry, and is re-applied in `applyTranslations()`
when the language changes. "OpenGL" is a brand name and is not translated.
## 5. Configuration system
- `qs_config.c` holds one parsed struct per group (`video_options`, `general`,
`player_position`, `player_controls`), a parse function, an apply function,
and the writer `qs_config_write_file()`. Groups are tagged live-appliable or
restart-required (`QS_*_REQUIRES_RESTART`).
- Load order at startup: `questshock.config.json`, else
`questshock.config.default.json` (bundled, refreshed on every launch), else
the engine's own `prefs.txt` behaviour. Save is only ever an explicit menu
action.
- Invalid values are logged and skipped individually; unknown keys are ignored.
**Adding an option:** parsed-struct field, parse, apply (with validation and a
`WARN` on invalid input), write in `qs_config_write_file()`, restart flag,
`CONFIG.md`, and (if it should have a shipped default) `res/questshock.config.default.json`.
The virtual screen has two profiles, "initial" (splash, cutscenes, main menu)
and "game", switched by `qs_screen_profile_notify_loop_mode()` from engine
patch 22. `s_screen_profile_is_game` in `qs_config.c` doubles as "a game is
running" and gates the cheats and the menu pause.
## 6. Engine patches
This section is written for `android/engine-patches/`; `engine-patches/`
(general fixes, also applied to the desktop Linux/Windows builds - README
section 4.4) follows the same process below except: no `#ifdef __ANDROID__`
guard (these aren't Android-specific), save as
`engine-patches/NN-desktop-<topic>.patch`, and there is no Gradle
`stageEngine` to worry about going stale - `build-image/build-engine.sh`/
`build-engine-win.sh` re-stage and re-patch on every build.
Rules:
- Guard every engine change with `#ifdef __ANDROID__` so the desktop build is
unaffected, and keep hunks minimal.
- Patches are applied in filename order with `patch -p1` from the engine root
(paths look like `a/src/GameSrc/hkeyfunc.c`). Patch N is generated against
the tree with patches `01..N-1` applied.
- Some engine files use CRLF line endings (for example `mouselook.c`). Edits
and generated patches must preserve them byte for byte; check with `file`
or `cat -A`. An editor or script that rewrites line endings produces a
whole-file diff.
- Do not hand-edit hunk headers. Regenerate the patch from a scratch copy.
Creating or changing a patch:
1. Make a scratch copy of `engine/` (for example under the session scratch
directory) and apply patches `01..N-1` to it (`patch -p1 -d <scratch> < ...`).
2. Copy the target file(s) to a second location, edit them in the scratch copy.
3. Produce the patch with `diff -u` using `a/` and `b/` path prefixes relative
to the engine root, and save it as `android/engine-patches/NN-android-<topic>.patch`.
4. Dry-run the whole series on a fresh copy of `engine/`
(`patch -p1 --dry-run`, or apply for real on the copy) and confirm every
patch applies cleanly, with the patched file byte-identical to what you
edited.
5. Keep numbering contiguous, and update the README patch reference (5.4) and
any code comments that cite patch numbers.
Gradle's `stageEngine` task re-stages and re-patches on build, but it can wrongly
report `UP-TO-DATE` when a **new** patch file appears. If a native change does
not show up on the device, tell the maintainer to run `./gradlew --stop`, or
`android/force-rebuild.sh`, or delete `build/android-engine`, before assuming
the change is wrong.
Engine symbols used from first-party code (for example `game_paused`,
`pause_game_func`, `toggle_giveall_func`, `qs_cheat_refill`,
`qs_in_cyberspace`) are declared `extern` next to their use in `qs_config.c` or
`xr_input.c`, with a comment naming the engine file and patch.
## 7. Controller mapping (`xr_input.c`)
Structure:
- Physical controls are OpenXR actions, suggested once against three
interaction profiles: Meta Touch, HTC Vive wand and the core
`khr/simple_controller`. A single wrong path makes the whole
`xrSuggestInteractionProfileBindings` call fail, so each profile lists only
components it really has. A failure calls
`qs_config_report_fatal_input_error()` and aborts input init.
- The **raw-input layer** (`QsRawInput`) digitizes every physical control
(buttons, triggers, squeezes, thumbstick clicks, and eight thumbstick
directions with hysteresis) each frame. `QS_RAW_NONE` means unbound.
- **Actions** (`QsControlAction`) are bound to raw inputs through `s_binding[]`.
The default bindings are `kDefaults[]`.
- Contexts: `ALWAYS`, `NORMAL` (move, strafe, turn, jump, stand, crouch, prone)
and `CYBER` (the eleven `cyber_*` actions). The active set is chosen with
`qs_in_cyberspace()` (patch 24). Two actions conflict only if their contexts
can be active together (`xr_input_contexts_overlap()`).
- Capture mode (`xr_input_process_capture()`): the next fresh raw down-edge is
assigned to the chosen action. Any other action in an overlapping context that
held that input (or its symmetric alias) is set to `none`. The reset and the
timeout (8 s) paths never leave a half-assigned state.
- The menu button is not a raw input and can never be bound, so the menu can
always be reached to press **Reset to Defaults**.
- While the menu is open, `mouse_click` is always the trigger (the controller
used to operate the menu); after it closes, the bound `mouse_click` drives
both the keyboard and the game quad.
- While the Controller Options tab is open, the menu's own thumbstick
snap-turn and smooth-move are disabled
(`g_controller_options_tab_open`, set through
`nativeSetControllerOptionsTabOpen`).
- Free view: holding a `free_*_thumbstick_view` binding turns that stick into
the engine's mouse look (cursor forced hidden, stick direction bindings
suspended). It uses `SDL_ShowCursor(SDL_DISABLE)` because the engine zeroes
mouse-look velocity while the cursor is visible.
- Cyberspace pitch and bank scale with stick deflection through the globals
from patch 25 (`qs_cyber_pitch_scale`, `qs_cyber_bank_scale`).
### Adding or changing a bindable action (checklist)
All of these lists share one order and must stay identical:
1. `QsControlAction` enum in `xr_input.c` (and append before `QS_ACTION_COUNT`).
2. `kActionNames[]` in `xr_input.c`.
3. `s_binding[]` initial values and `kDefaults[]` in `xr_input.c`.
4. If it injects a key: a `kKeyActions[]` entry (keycode, modifiers, whether it
needs `SDL_TEXTINPUT`); add the key to the stuck-key watchdog list
(`kWatchedKeys`) if it is held continuously.
5. `ACTION_KEYS` in `MenuOverlay.java`, and `FIRST_ROW_ACTIONS` if it should be
listed first.
6. `Strings.java`: `action_<key>` in EN/FR/DE.
7. `QS_MAX_PLAYER_CONTROL_ACTIONS` in `qs_config.c` if the count outgrows it.
8. `CONFIG.md`: the action tables and the count in the Player Controls intro;
README default-controls table if it has a default binding.
9. Check that no two actions that can be active together share a default input.
### Injecting keys into the engine
- Plain printable keys (letters, digits, Space, punctuation) need an
`SDL_TEXTINPUT` event plus `SDL_KEYDOWN`/`SDL_KEYUP`. Modifier, control-range,
F and keypad keys use `SDL_KEYDOWN`/`SDL_KEYUP` with the right `mod`.
- **Send the release on the real up edge, never in the same call as the
press.** The engine polls movement keys once per frame before it drains
events, so a press and release drained together are never seen and the key
silently does nothing. Tie the release to the physical button's up edge (or
the touch's `ACTION_UP`/`ACTION_CANCEL`).
- Held keys are tracked in the engine's `sshockKeyStates[]`; `xr_input.c`
keeps a watchdog that clears a watched key the engine still thinks is down
after its binding is released.
- Actions that share an engine key (for example the several ways to fire, or
the movement keys) are handled with sibling logic in the dispatch loop
(`wantDown[]`); do not press or release the shared key independently.
## 8. Cheats tab
- Buttons and switches live in `MenuOverlay.buildCheatsTab()`; ids are
`QS_CHEAT_*` in `qs_config.c` and `CHEAT_*` in `MenuOverlay.java` (keep them
identical).
- The engine's own cheat handlers (`toggle_giveall_func`, `toggle_physics_func`,
`toggle_up_level_func`, `toggle_down_level_func`) are called on the engine
thread through `qs_apply_cheat()`, and only while a game is running.
- The four unlimited switches are a bitmask in `qs_config.c`
(`QS_CHEAT_MASK_*`: health 1, energy 2, ammo 4, items 8). Every frame
`qs_config_frame_tick()` calls `qs_cheat_refill(mask)` (patch 26). The mask
values must match the patch. The switches are not persisted.
- The four `cheat_*` hotkeys are deliberately not bindable in
`player_controls`. New cheats go on the Cheats tab.
## 9. Menu pause
Opening the menu pauses a running game (`qs_apply_menu_pause()` through the
menu-visibility event); closing it resumes only if the menu did the pausing and
the game is still paused. Do not toggle pause blindly, since the pause function
toggles.
## 10. Bundled files on the device
`QuestShockActivity` extracts these into `/sdcard/QuestShock/`:
- shaders, the default soundfont and `GET_ASSETS_QUEST.txt` (once);
- `questshock.config.default.json` and `controller_profiles/` (every launch,
overwriting only the bundled filenames so user-added profiles survive).
`res/splash.png` stays in the APK assets and is not extracted to the device;
`QuestShockActivity` decodes it and hands the pixels to `qs_splash.c`.
`res/controller_profiles/`, `res/splash.png` and the default config are staged
into APK assets by `build-image/prepare-android-project.sh`; every staged file
or directory must also be declared as `inputs.file(...)`/`inputs.dir(...)` of
`stageEngine` in `android/app/build.gradle`.
The proprietary game data (`res/data/`, `res/sound/`) is never bundled or
committed. The app refuses to start the engine until it is present.
## 11. Signing
Distributed APKs are release builds signed with one fixed key, so users can
update in place. `build-image/build-apk.sh` reads the key from
`QUESTSHOCK_KEYSTORE`, `QUESTSHOCK_KEYSTORE_PASSWORD`, `QUESTSHOCK_KEY_ALIAS`
and `QUESTSHOCK_KEY_PASSWORD`; `android/app/build.gradle` applies it to the
`release` build type only when `QUESTSHOCK_KEYSTORE` is set. Both CI workflows
decode it from the `ANDROID_KEYSTORE_BASE64` secret. Without a key the build is
a debug APK (`...-debug.apk`), which is only for testing. Never commit a
keystore or a password, print them, or put them in a workflow file (README
section 4.3 lists the secrets).
## 12. Code and documentation style
- Match the surrounding code: naming, comment density, idiom. C is plain C with
`static` helpers and `//` comments; Java targets Android API 34 and uses only the SDK plus
`androidx.core`.
- Keep functions and file-level constants near their use. Put magic numbers in
named constants (see `QS_FREE_VIEW_*`, `QS_CYBER_ROTATION_*`).
- Keep the `xr_input.h` and `qs_config.h` declarations documented with a short
comment saying which thread may call them.
- Comments: what and why, current state only (rule 5 in section 2).
- Docs: README.md is organised by numbered sections that other text cites
("5.5.3"); do not renumber existing sections. Add new material as a new
subsection instead.
- English, French and German strings live in `Strings.java`; keep the three
entries of each key on the same `put(...)`.
## 13. Verifying a change without building
Because you may not build (rule 2), check statically and say so in the report:
- **Patches:** apply the whole series to a fresh copy of `engine/` and confirm
no rejects. Confirm the edited file matches the patched result byte for byte.
- **JNI:** every `native` method in the Java classes has a
`Java_de_ladkau_questshock_<Class>_<method>` export with a matching signature,
and no export is left without a Java declaration.
- **Ordered lists:** `QsControlAction`, `kActionNames[]`, `s_binding[]`,
`kDefaults[]` and `ACTION_KEYS` have the same length and order.
- **Strings:** every `Strings.get("...")` key exists in `Strings.java`.
- **Balance:** braces and parentheses balance. `qs_config.c` reports a
brace-count offset of +5 from character literals containing `'{'`; that is
not a bug.
- **Docs:** README and CONFIG.md agree with the code (action counts, default
bindings, patch list).
Then report what was checked, what was not (build, on-device behaviour), and
any device check the maintainer should do (for example a new patch that may
need `android/force-rebuild.sh`).
## 14. Commit messages
Draft only (rule 1). Use a short imperative subject (about 70 characters), a
blank line, then a body that explains what the change does and why: the
user-visible behaviour, the mechanism, and any constraint worth remembering.
Mention new files that need `git add`.
+458
View File
@@ -0,0 +1,458 @@
# Persistent configuration (`questshock.config.json`)
QuestShock can load a persistent, human-editable configuration file at
startup, in addition to (and independent of) the engine's own `prefs.txt`.
- **Location**: `/sdcard/QuestShock/questshock.config.json` - the same
folder you already copy `res/data`/`res/sound` into (see README.md
section 5.1).
- **Format**: plain JSON, grouped by concern. Each top-level key is a
*group*; each key inside a group is one *option*.
- **Loading**: read automatically at app startup, before the splash screen,
if the file exists. If it doesn't, `questshock.config.default.json` (same
folder) is used instead - a copy bundled with the app, refreshed on every
launch, never overwriting your own saved `questshock.config.json`. If
neither file exists, the engine behaves as it did before this file
existed (driven by `prefs.txt` as usual).
- **Saving**: never automatic. In-headset, open the menu (controller menu
button) and press **Save Configuration** to write the file from your
current live settings, overwriting it if present. Press **Load
Configuration** to re-read the file and apply it without restarting the
app.
- **Live vs. restart-required**: every option below is tagged as either
*live-appliable* (applying it while already playing takes effect
immediately) or *restart-required*. Pressing **Load Configuration**
applies every live-appliable change straight away; if the file also
changes something restart-required, a dialog lists exactly which
option(s) need it and offers:
- **Dismiss** - keep playing with the live-appliable changes already
applied; the restart-required option(s) stay as they were.
- **Restart Now** - relaunches the app, which re-runs this same startup
load and so applies every group in the file, including the
restart-required ones.
(Every option currently documented below is live-appliable, so this
dialog won't actually appear yet - it's built in for options added in a
future version that do need a restart.)
- Unrecognized groups/keys are ignored (harmless if you're editing the file
in advance for a future version). An invalid value for a *specific*
option (see each option's possible values below) is logged and that one
option is skipped - every other valid option in the file still applies.
A file that isn't valid JSON at all is ignored entirely, with nothing
applied.
- `questshock.config.json` and `prefs.txt` are fully independent - loading
or saving one never reads or writes the other.
## Example
```json
{
"video_options": {
"renderer": "opengl",
"resolution": "1024x768",
"initial_screen_width_m": 1.6,
"initial_screen_distance_m": 2.0,
"game_screen_width_m": 2.8,
"game_screen_distance_m": 2.0,
"screen_width_offset_m": 1.0,
"screen_width_step_m": 0.6,
"screen_distance_offset_m": 1.0,
"screen_distance_step_m": 0.5
},
"general": {
"language": "english"
},
"player_position": {
"x": 0.0,
"z": 0.0,
"yaw_degrees": 0.0
},
"player_controls": {
"move_forward": "left_thumbstick_up",
"move_back": "left_thumbstick_down",
"strafe_left": "left_thumbstick_left",
"strafe_right": "left_thumbstick_right",
"turn_left": "right_thumbstick_left",
"turn_right": "right_thumbstick_right",
"jump": "left_thumbstick_click",
"mouse_click": "right_a",
"mouse_double_click": "right_b",
"stand": "right_thumbstick_up",
"prone": "right_thumbstick_down",
"map_view": "right_thumbstick_click",
"fire_weapon": "right_trigger"
}
}
```
## Video Options (`video_options`)
### `renderer`
Which renderer the game uses to draw the 3D view.
- **Type**: enum
- **Possible values**: `"software"`, `"opengl"`
- **Restart required?**: No (live-appliable)
### `resolution`
The game's internal rendering resolution.
- **Type**: enum
- **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"`
- **Restart required?**: No (live-appliable)
### `initial_screen_width_m`, `initial_screen_distance_m`, `game_screen_width_m`, `game_screen_distance_m`
The virtual screen's physical width and distance from the player (both in
meters) - the game shows two independently configurable screen
"profiles", switched automatically based on what's currently on screen:
- **Initial Screen** (`initial_screen_*`) - the splash screen, intro
cutscenes, and the game's own main menu.
- **Game Screen** (`game_screen_*`) - actual gameplay (the 3D view,
automap, cyberspace, etc.).
Switching happens automatically - no player action needed - the instant
the game itself transitions between these (e.g. starting/loading a game,
or returning to the menu).
- **Type**: number (meters)
- **Possible values**: the in-headset Video Options tab offers 5 presets
each - widths `1.0`, `1.6` (the original, pre-this-feature default),
`2.2`, `2.8`, `3.4`; distances `1.0`, `1.5`, `2.0` (the original,
pre-this-feature default), `2.5`, `3.0` - but a hand-edited value
doesn't need to match a preset exactly (it's clamped to a sane range -
width `[0.2, 10.0]`, distance `[0.5, 10.0]` - rather than snapped or
rejected; the Video Options tab just shows no button selected for an
in-between value).
- **Restart required?**: No (live-appliable)
- **Note**: the screen is always flat - there's no curve option.
### `screen_width_offset_m`, `screen_width_step_m`, `screen_distance_offset_m`, `screen_distance_step_m`
Reshapes the 5 preset buttons the Video Options tab offers for width and
distance (above) - config-file-only, there's no in-headset control for
these. Shared by both **Initial Screen** and **Game Screen** (both
columns' buttons show the same 5 values per row) - this only changes
what the buttons offer, not either profile's current value.
Preset button `index` (0-4) shows `offset + step * index`. For example,
a distance offset of `0` and step of `2` shows buttons `0`, `2`, `4`,
`6`, `8`; the defaults below reproduce this feature's original launch
presets exactly.
- **Type**: number (meters)
- **Default values**: width offset `1.0`, step `0.6` (buttons `1.0`,
`1.6`, `2.2`, `2.8`, `3.4`); distance offset `1.0`, step `0.5` (buttons
`1.0`, `1.5`, `2.0`, `2.5`, `3.0`).
- **Restart required?**: No (live-appliable)
- **Note**: not clamped - an unusual offset/step (e.g. negative step) is
accepted as-is. A resulting preset value outside the current value's
own clamp range (see above) still clamps normally when that preset
button is actually pressed.
## General (`general`)
### `language`
The game's UI/text language - both the game's own resource strings and this
project's own menu/keyboard chrome text (title bar, buttons, etc.).
- **Type**: enum
- **Possible values**: `"english"`, `"french"`, `"german"`
- **Restart required?**: No (live-appliable) - applies the same way the
game's own in-game options menu applies a language change (reloads the
language-specific resource files and refreshes the inventory/MFD/side-icon
UI immediately).
- **Note**: French and German both need their own full resource pack
(`frnstrng.res`/`mfdfrn.res`, `gerstrng.res`/`mfdger.res` under
`res/data/`) - if any of those files aren't present, the switch is
refused (logged, `questshock.config.json` and the current language are
left unchanged) rather than leaving the game with no valid resource file
open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
include them.
- The intro cutscene is also played in the selected language when its
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is
present; otherwise the English intro plays.
## Player Position (`player_position`)
Where the player is virtually standing/facing, independent of - and not
reset by - starting a new game or loading a save (same independence from
`prefs.txt`/savegames as every other group here). Set by moving around
in-headset (menu open, left thumbstick snap-turns, right thumbstick smooth-
moves) to line yourself up with the game quad ("virtual screen"). Height is
never included here - it always comes from your real tracked headset
height, not this file.
**Startup fallback**: only applied at startup if all three fields (`x`,
`z`, `yaw_degrees`) are present - a config file with none of them (or none
at all), or a partial/hand-edited one missing any of the three, falls back
to an automatic recenter instead (see **Reset Position** below), using
your real head pose at that moment. A complete saved position is applied
as-is, with no such recenter.
**Reset Position** (Main Menu tab) is a live-only action, not a config
value: it moves you to the centre in front of the game quad, based on where
your head actually is right now. It resets only your *position*, not which
way you're facing, so the screen does not rotate to follow your gaze - press
it while looking off to one side and the screen stays off to the other side.
The automatic recenter at startup (when there's no saved position) does
face the screen towards where you're looking. Reset Position never reads or
writes `questshock.config.json`.
### `x`, `z`
Horizontal position, in meters, on the play space's floor plane.
- **Type**: number
- **Restart required?**: No (live-appliable)
### `yaw_degrees`
Which way the player is virtually facing, in degrees.
- **Type**: number
- **Restart required?**: No (live-appliable)
## Player Controls (`player_controls`)
Maps each of 57 in-game actions to a physical controller input: 9
movement/click actions, 2 free-view actions, 11 cyberspace actions, and 35
for the engine's own keyboard hotkeys and firing (posture, save/load, weapon
select, MFD panels, etc. - see the hotkey table below). Some actions are bound by default (see the default-input
columns); the rest are unbound and can be bound on demand. Assign them in the
**Controller Options** menu tab: tap an action's binding button, then press
the button or stick direction to use ("Press a button…"); tap it again, or
wait 8 seconds, to cancel without changing it.
- **Type**: one enum-string field per action (below)
- **Restart required?**: No (live-appliable) - a rebind takes effect
immediately, and persists across restarts once saved (**Save
Configuration**, same as every other group here).
- **Menu movement**: while the Controller Options tab is open, the menu's own
thumbstick snap-turn and smooth-move (see `player_position`) are disabled, so
pressing inputs to assign them doesn't move you away from the screen.
- **Not included here**: opening/closing the VR menu itself is a fixed,
non-remappable button (see "Menu button" below) - there's no
`menu_toggle` field, and the Controller Options tab never lets you
reassign it. This guarantees you can always reach this tab and press
**Reset to Defaults** to recover from any bad rebind, no matter what else
you've reassigned.
- **Reset to Defaults**: restores the "Default input" column below for
every action not overridden by a `player_controls` group in
`questshock.config.default.json` - if that file defines one (it doesn't,
out of the box), the values it specifies are applied on top of the table
below instead, so you can customize what "reset" means without a rebuild.
This is independent of your own `questshock.config.json` - resetting
doesn't read or write that file, just the live bindings (**Save
Configuration** afterward to persist them).
### Actions
| Action key | Default input | Effect |
|---|---|---|
| `move_forward` | `left_thumbstick_up` | Walk forward |
| `move_back` | `left_thumbstick_down` | Walk backward |
| `strafe_left` | `left_thumbstick_left` | Step sideways, left |
| `strafe_right` | `left_thumbstick_right` | Step sideways, right |
| `turn_left` | `right_thumbstick_left` | Turn left |
| `turn_right` | `right_thumbstick_right` | Turn right |
| `jump` | `left_thumbstick_click` | Jump |
| `mouse_click` | `right_a` | Click - operates the keyboard/game quad's cursor, whichever the pointing hand's ray is over |
| `mouse_double_click` | `right_b` | Double-click - game quad only (e.g. using an inventory item); has no effect over the menu or keyboard |
| `free_left_thumbstick_view` | `left_squeeze` | Hold to look around with the left thumbstick (see below) |
| `free_right_thumbstick_view` | `none` | Hold to look around with the right thumbstick (see below) |
Movement actions bound to a thumbstick direction are suppressed while the
VR menu is open, since the same physical stick already snap-turns/smooth-
moves the player in that state (see `player_position` above) - this is the
one case where a binding's effect depends on whether the menu is open, and
it only ever affects the 8 thumbstick-direction inputs below.
**Free view** (`free_left_thumbstick_view`, `free_right_thumbstick_view`) is
the engine's mouse look driven by a thumbstick. While the bound input is held,
that stick looks around instead of doing its normal job: left/right turns the
view, up/down looks up and down, in proportion to how far the stick is pushed.
The stick's own direction bindings (move, strafe, turn, stand/prone) are
suspended while it is held, and the cursor stays centred in the view. Release
the input to return to normal. It has no effect while the VR menu is open.
### Cyberspace actions
Cyberspace has its own set of movement actions, active only while the player
is in cyberspace. While in cyberspace the normal movement actions
(`move_*`, `strafe_*`, `turn_*`, `jump`, `stand`, `crouch`, `prone`) are
inactive, and the cyberspace actions are inactive everywhere else. Each set
has its own bindings, so one input can be bound in both (for example the left
stick up is `move_forward` on foot and `cyber_climb` in cyberspace);
the Controller Options tab only reports a conflict between actions that can
be active at the same time. Free view, clicking and firing work in both.
Pitch and bank (`cyber_climb`, `cyber_dive`, `cyber_bank_*` and the four
diagonals) are proportional when bound to a thumbstick direction: pushing the
stick further turns faster, from 20% of full speed just past the trigger point
to 60% at full deflection. Bound to a button they run at full speed. Thrust and
roll are always full speed.
| Action key | Default input | Keyboard equivalent | Effect |
|---|---|---|---|
| `cyber_thrust` | `right_thumbstick_up` | `s` | Thrust forward |
| `cyber_climb` | `left_thumbstick_up` | `w` | Pitch up |
| `cyber_dive` | `left_thumbstick_down` | `x` | Pitch down |
| `cyber_bank_left` | `left_thumbstick_left` | `a` | Bank left |
| `cyber_bank_right` | `left_thumbstick_right` | `d` | Bank right |
| `cyber_roll_left` | `right_thumbstick_left` | `e` | Roll left |
| `cyber_roll_right` | `right_thumbstick_right` | `q` | Roll right |
| `cyber_climb_left` | `none` | Keypad 7 | Pitch up and bank left |
| `cyber_climb_right` | `none` | Keypad 9 | Pitch up and bank right |
| `cyber_dive_left` | `none` | Keypad 1 | Pitch down and bank left |
| `cyber_dive_right` | `none` | Keypad 3 | Pitch down and bank right |
`mouse_click` additionally behaves differently depending on the menu:
while the VR menu is open, its "click" is always whichever hand's trigger
is used to interact with the menu (so you can never lock yourself out of
this settings screen by rebinding `mouse_click` elsewhere); once the menu
is closed, both the on-screen keyboard (if it's still open) and the game
quad use your actual `mouse_click` binding. The default (`right_a`) is a
specific button on the right controller, so only the right hand's ray clicks;
bind the `trigger` identifier (see below) for either hand to click.
### Hotkey actions
The engine's own keyboard hotkeys (`HotKeyLookup[]` in `Prefs.c`) plus
firing, as 35 bindable actions. `stand` (`right_thumbstick_up`), `prone`
(`right_thumbstick_down`), `map_view` (`right_thumbstick_click`) and
`fire_weapon` (`right_trigger`) are bound by default; the rest are unbound
(`none`). Each fires once per press, like pressing the key on a real
keyboard (there is no hold behavior).
| Action key | Keyboard equivalent | Effect |
|---|---|---|
| `stand` | `t` | Stand up |
| `crouch` | `g` | Crouch |
| `prone` | `b` | Go prone |
| `pause` | `p` | Pause the game |
| `full_view` | Ctrl+F | Switch to the fullscreen view loop |
| `normal_view` | Ctrl+D | Switch to the normal gameplay view |
| `map_view` | Ctrl+A | Switch to the automap view |
| `save_game` | Ctrl+S | Save the game |
| `load_game` | Ctrl+L | Load the game |
| `select_grenade` | Ctrl+' | Select the next grenade type |
| `arm_grenade` | Alt+' | Arm the selected grenade |
| `select_drug` | Ctrl+; | Select the next drug/patch |
| `use_drug` | Alt+; | Use the selected drug/patch |
| `toggle_online_help` | Ctrl+H | Toggle the online help overlay |
| `show_help` | Alt+O | Show the help screen |
| `toggle_music` | Ctrl+M | Toggle music |
| `quit` | Ctrl+Q | Quit the game - opt-in only; nothing binds this by default |
| `cycle_weapon_next` | Tab | Ready the next weapon |
| `cycle_weapon_prev` | Shift+Tab | Ready the previous weapon |
| `cycle_detail` | Ctrl+1 | Cycle the graphics detail level |
| `reload_weapon_primary` | Ctrl+Backspace | Reload the current weapon's primary ammo |
| `reload_weapon_secondary` | Alt+Backspace | Reload the current weapon's secondary ammo |
| `hud_color` | Alt+H | Cycle the HUD color scheme |
| `clear_fullscreen` | Backspace | Close a fullscreen view (e.g. a datapad or email) |
| `mfd_left_1`-`mfd_left_5` | F1-F5 | Set the left MFD panel to Weapon/Item/Map/Target/Info |
| `mfd_right_1`-`mfd_right_5` | F6-F10 | Set the right MFD panel to Weapon/Item/Map/Target/Info |
| `fire_weapon` | Enter | Fire the readied weapon at the cursor (the engine's default fire key). Clicking in the 3D view with `mouse_click` also fires; this is a separate button for it |
**Not exposed** (other `HotKeyLookup[]` entries):
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
on-screen keyboard.
- `toggle_freelook` (F): the free-view actions cover mouse look.
- The 4 `cheat_*` entries: debug cheats. They are on the menu's Cheats tab
instead, along with four switches for unlimited health, energy, ammo and
items. Those are not saved to the config.
- `mac_help` (disabled) and `audiolog_cancel` (its build flag is not
defined): they are not real hotkeys in this build.
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
toggle the same setting.
- The 10 numeric hardware-item hotkeys (bio scan, fullscreen, 360 view,
lantern, shield, infrared, nav unit, data reader, booster, jumpjets): their
registered keys never match a real keypress in the engine.
### Raw input identifiers
Every raw physical input available to bind an action to, with its generic
OpenXR interaction-profile component name and its Meta Quest Touch control
name. Two symmetric identifiers (`trigger`, `squeeze`) are also accepted, but
only for `mouse_click`/`mouse_double_click`: they mean "either hand's own
trigger/grip, whichever hand is pointing". Every other identifier below
refers to one specific control.
| Identifier | OpenXR component (Touch) | Meta Quest control |
|---|---|---|
| `left_trigger` | `/user/hand/left/input/trigger/value` | Left Trigger |
| `right_trigger` | `/user/hand/right/input/trigger/value` | Right Trigger |
| `left_squeeze` | `/user/hand/left/input/squeeze/value` | Left Grip |
| `right_squeeze` | `/user/hand/right/input/squeeze/value` | Right Grip |
| `left_x` | `/user/hand/left/input/x/click` | X Button |
| `left_y` | `/user/hand/left/input/y/click` | Y Button |
| `right_a` | `/user/hand/right/input/a/click` | A Button |
| `right_b` | `/user/hand/right/input/b/click` | B Button |
| `left_thumbstick_click` | `/user/hand/left/input/thumbstick/click` | Left Thumbstick Click |
| `right_thumbstick_click` | `/user/hand/right/input/thumbstick/click` | Right Thumbstick Click |
| `left_thumbstick_up/down/left/right` | `/user/hand/left/input/thumbstick` (digitized) | Left Thumbstick, tilted |
| `right_thumbstick_up/down/left/right` | `/user/hand/right/input/thumbstick` (digitized) | Right Thumbstick, tilted |
| `trigger` | either hand's trigger | Either Trigger (`mouse_click`/`mouse_double_click` only) |
| `squeeze` | either hand's grip | Either Grip (`mouse_click`/`mouse_double_click` only) |
| `none` | - | Unbound - the action never fires |
**Menu button** (left controller, Touch/Vive/generic alike) is not in this
list - it always opens/closes the VR menu and can never be reassigned.
**Other supported controllers**: HTC Vive wands have no A/B/X/Y and no
analog grip - `left_x`/`left_y`/`right_a`/`right_b` simply never fire on
that profile, and `left/right_squeeze` are a plain click rather than
analog. Their trackpad stands in for the thumbstick identifiers above
(same digitization, just a trackpad instead of a stick). A bare-minimum
OpenXR controller (`khr/simple_controller`, used as a fallback when
neither Touch nor Vive bindings are suggested) only has `left_trigger`/
`right_trigger` (via its `select/click` component) and the menu button -
every other identifier above simply never fires on it, which is why the
Controller Options tab shows a note and hides irrelevant rows when this
profile is active.
### Controller profile files (`controller_profiles/`)
The Controller Options tab shows a labeled illustration of whichever
controller is actually active, driven by `res/controller_profiles/*.json`
(bundled with the app, copied to `/sdcard/QuestShock/controller_profiles/`
on every launch, refreshing the known bundled files without touching any
extra ones you add there yourself). Each file:
```json
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "right_b", "label": "B", "anchor_x": 0.76, "anchor_y": 0.22}
]
}
```
- `interaction_profile` - the OpenXR interaction profile path this file
describes; matched against whichever one is actually bound at runtime.
Falls back to `generic.json` if nothing matches.
- `image` - filename (same folder) of the controller illustration.
- `controls` - one entry per labeled point on that illustration: `id` is a
raw input identifier from the table above (thumbstick directions share
one `left_thumbstick`/`right_thumbstick` entry, since they're the same
physical stick); `anchor_x`/`anchor_y` are 0-1 fractions of the image for
positioning that label's pointer line.
This is presentation data only - adding real support for a new controller
still needs matching bindings in the app itself; a profile file with no
corresponding code-level support just never gets matched.
### Fatal startup error
If the OpenXR runtime rejects any of the three supported controller
profiles' bindings at launch (an app bug, not something you did), a
non-cancelable dialog explains this and exits - this should never happen
in a normal build.
+122 -32
View File
@@ -1,20 +1,28 @@
# Assembles dist/ - a self-contained, runnable copy of System Shock -
# from the compiled engine (built via Docker, see build-image.sh/
# run-image.sh) and the game assets extracted from a purchased copy (see
# Assembles dist/ (Linux) and dist-win/ (Windows, cross-compiled via
# MinGW) - self-contained, runnable copies of System Shock - from the
# compiled engine (built via Docker, see build-image.sh/run-image.sh) and
# the game assets extracted from a purchased copy (see
# res/assets/extract_assets.sh). Building the engine needs the build-image
# (./build-image.sh, once); everything else here is plain file copying.
# `dist-linux`/`dist-win` build just one platform; plain `dist` builds
# both.
#
# `make package` instead builds a redistributable tarball that omits the
# proprietary game assets entirely (see res/assets/GET_ASSETS.txt, which
# it ships in their place) - this is what CI publishes.
# `make package` instead builds redistributable archives (a .tar.gz for
# Linux, a .zip for Windows) that omit the proprietary game assets
# entirely (see res/assets/GET_ASSETS.txt, which they ship in their
# place) - this is what CI publishes. Same dist-linux/dist-win/(both)
# split via package-linux/package-win/package.
DIST_DIR := dist
ENGINE_OUT := engine/.build-output
ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build
ARCH := $(shell uname -m)
DIST_DIR := dist
DIST_WIN_DIR := dist-win
ENGINE_OUT := engine/.build-output
ENGINE_OUT_WIN := engine/.build-output-win
ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build
ARCH := $(shell uname -m)
.PHONY: all dist build-image engine assets package clean
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
package package-linux package-win apk android-studio clean
all: dist
@@ -40,6 +48,16 @@ engine:
./run-image.sh; \
fi
# Cross-compiles engine/ for Windows via MinGW - see build-engine-win.sh
# for why this can't share build-engine.sh's engine/build_ext/ (a scratch
# copy is used instead). Same QUESTSHOCK_BUILD_IMAGE detection as `engine`.
engine-win:
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
bash build-image/build-engine-win.sh; \
else \
./run-image.sh bash build-image/build-engine-win.sh; \
fi
# Fails with a pointer to extract_assets.sh if the purchased game assets
# haven't been extracted yet.
assets:
@@ -49,10 +67,13 @@ assets:
exit 1; \
fi
dist: engine assets
# Builds both platforms. `dist-linux`/`dist-win` build just one.
dist: dist-linux dist-win
dist-linux: engine assets
@echo "== Assembling $(DIST_DIR) =="
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh" "$(DIST_DIR)/run-mods.sh"
mkdir -p "$(DIST_DIR)/lib" "$(DIST_DIR)/res/data" "$(DIST_DIR)/res/sound"
cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/"
cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/"
@@ -62,32 +83,44 @@ dist: engine assets
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_DIR)/res/sound/"
cp res/run.sh "$(DIST_DIR)/run.sh"
chmod +x "$(DIST_DIR)/run.sh"
cp res/run-mods.sh "$(DIST_DIR)/run-mods.sh"
chmod +x "$(DIST_DIR)/run-mods.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory
# first, so (unlike run.sh's LD_LIBRARY_PATH) run.bat needs no extra
# wiring for that.
dist-win: engine-win assets
@echo "== Assembling $(DIST_WIN_DIR) =="
rm -rf "$(DIST_WIN_DIR)"
mkdir -p "$(DIST_WIN_DIR)/res/data" "$(DIST_WIN_DIR)/res/sound"
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$(DIST_WIN_DIR)/"
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$(DIST_WIN_DIR)/res/"
cp -a engine/shaders "$(DIST_WIN_DIR)/shaders"
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
cp res/run-mods.bat "$(DIST_WIN_DIR)/run-mods.bat"
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/
# `package-win` build just one.
package: package-linux package-win
# Builds a versioned, redistributable Linux release tarball at
# dist/questshock-<version>-linux-<arch>.tar.gz - everything needed to
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
# to get those instead of shipping res/data/, res/sound/). Version
# defaults to the current git tag (vX.Y.Z, tag prefix stripped) - push a
# tag to drive a release. Override with `make package VERSION=1.2.3`, or
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
# placeholder version, with a warning).
package: engine
package-linux: engine
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PKG_NAME="questshock-$$V-linux-$(ARCH)"; \
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
@@ -107,8 +140,65 @@ package: engine
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
# Windows counterpart of package-linux: dist/shockolate-<version>-windows-
# x86_64.zip. Same versioning (build-image/version.sh) and VERSION=
# override. Needs `zip` on whatever host runs `make package-win` itself
# (unlike engine-win's own build, packaging isn't run inside the
# build-image) - see README's Desktop build prerequisites.
package-win: engine-win
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not found in PATH" >&2; exit 1; }
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-windows-x86_64"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE/res"; \
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$$PKG_STAGE/"; \
cp -a engine/shaders "$$PKG_STAGE/shaders"; \
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$$PKG_STAGE/res/"; \
cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt"; \
cp res/run.bat "$$PKG_STAGE/run.bat"; \
cp LICENSE "$$PKG_STAGE/LICENSE"; \
cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \
cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \
mkdir -p "$(DIST_DIR)"; \
(cd "$(BUILD_DIR)/package" && zip -rq "$(CURDIR)/$(DIST_DIR)/$$PKG_NAME.zip" "$$PKG_NAME"); \
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.zip"
# Builds the Quest APK (see build-image/build-apk.sh and
# android/engine-patches/ - engine/ itself is never modified; a patch is
# applied to a scratch copy at build time instead). Same
# QUESTSHOCK_BUILD_IMAGE detection as `engine`. Unlike every other target
# here, this needs network access at build time (Gradle/AGP's own
# dependency resolution) - see build-image/Dockerfile.
apk:
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
bash build-image/build-apk.sh; \
else \
./run-image.sh bash build-image/build-apk.sh; \
fi
# Stages a scratch, patched copy of engine/ and android/gl4es-src/, plus
# the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts, with
# host-resolvable paths (writes android/engine.properties) so Android
# Studio can compile and deploy android/ natively - see README's "5.2.
# Building natively in Android Studio". `make apk` doesn't need this; it
# stages the same things itself inside the build-image. Re-run after
# `make clean` (which deletes android/engine.properties) or after
# changing engine/, android/engine-patches/, android/gl4es-src/,
# android/gl4es-patches/, or the prebuilt-library versions in
# build-image/Dockerfile - Android Studio's own Gradle build already
# does this automatically via its stageEngine task, so this is mainly
# useful for first-time setup or confirming staging succeeded on its own.
android-studio:
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
clean:
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \
rm -rf "$(DIST_DIR)" "$(DIST_WIN_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" "$(ENGINE_OUT_WIN)" \
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
engine/cmake_install.cmake engine/Makefile engine/systemshock \
engine/src/Libraries/CMakeFiles
engine/src/Libraries/CMakeFiles \
android/engine.properties android/app/src/main/assets android/app/src/main/jniLibs \
android/app/build android/.gradle android/app/.cxx
+42
View File
@@ -7,3 +7,45 @@ under the GNU GPLv3 - see LICENSE.Shockolate. It is included unchanged
and is not relicensed by this project's MIT license.
Game assets are not included - see res/GET_ASSETS.txt.
System Shock is a trademark of its respective owners. This is an unofficial
fan project and is not affiliated with, endorsed by or supported by them.
The Android build additionally bundles:
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
android/gl4es-patches/) and compiled from source at APK build time as
lib/arm64-v8a/libGL.so. It is MIT-licensed.
- The Khronos Group's OpenXR-SDK loader
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
The Windows build (cross-compiled via MinGW - see README's "4.2. Windows
cross-build") additionally bundles:
- GLEW (http://glew.sourceforge.net/), unmodified, as glew32.dll - used
for OpenGL extension loading, which Windows' own opengl32.dll doesn't
provide past OpenGL 1.1 (Linux instead gets this straight from Mesa's
headers, needing no separate loader library - see
engine/src/MacSrc/OpenGL.cc). It is licensed under a combination of the
Modified BSD License, the MIT License, and the Khronos License, all
permissive.
- The MinGW-w64 runtime's winpthreads library, as libwinpthread-1.dll. It
is MIT-licensed. (GCC's own runtime, libgcc/libstdc++, is linked
statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.)
The modding tools' `hacked` build (see modding/README.md; output under
modding/dist/*.tgz and *.zip) additionally bundles:
- InkyBlackness HackEd (https://github.com/inkyblackness/hacked), vendored
unmodified as modding/src/hacked/. It is New BSD-licensed.
- github.com/sqweek/dialog, vendored and patched (see
modding/src/sqweek-dialog-patches/) as modding/src/sqweek-dialog/. It is
ISC-licensed.
- hacked's further direct Go dependencies (fetched at build time, not
vendored in this repository): github.com/inkyblackness/imgui-go (New BSD);
github.com/go-gl/glfw, github.com/go-gl/mathgl, and golang.org/x/image
(BSD 3-Clause); github.com/go-gl/gl, github.com/asticode/go-astisub, and
github.com/lucasb-eyer/go-colorful (MIT).
+1101 -40
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
apply plugin: 'com.android.application'
// Paths written by build-image/prepare-android-project.sh (called by
// build-apk.sh, or standalone with --host-paths to prep for a native
// build in Android Studio - see README) since they're only known at
// prep/build time, not something a checked-in build.gradle can hardcode.
// engineDir is the scratch, patched copy of engine/; gl4esDir is the
// scratch, patched copy of android/gl4es-src/ (built from source as a
// CMake subdirectory of engine/'s own native build below, not prebuilt);
// prebuiltDir is the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr
// prebuilts - all point into this container's own paths for
// build-apk.sh's own gradlew call, or host-resolvable paths (plus a
// host-side export of prebuiltDir, since /opt/prebuilt/android only
// exists in the image) when prepared with --host-paths for Android
// Studio.
def engineProps = new Properties()
file("${projectDir}/../engine.properties").withInputStream { engineProps.load(it) }
def engineDir = engineProps.getProperty('engineDir')
if (engineDir == null) {
throw new GradleException("android/engine.properties is missing 'engineDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
}
def gl4esDir = engineProps.getProperty('gl4esDir')
if (gl4esDir == null) {
throw new GradleException("android/engine.properties is missing 'gl4esDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
}
def prebuiltDir = engineProps.getProperty('prebuiltDir')
if (prebuiltDir == null) {
throw new GradleException("android/engine.properties is missing 'prebuiltDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
}
// Set by build-apk.sh (see build-image/version.sh, shared with the
// desktop tarball's versioning) via -PquestshockVersionName/Code. Building
// directly from Android Studio (the normal day-to-day workflow - see
// README) never passes that property, so the default below has to carry
// its own weight rather than just being a fallback for some rare, other,
// unsupported path: it runs `git describe` itself, the same way
// engine/CMakeLists.txt's own PROJECT_REVERSION_STRING does for the
// engine's *own* separate version string - "vX.Y.Z" on a clean tagged
// commit, or "vX.Y.Z-N-gHASH[-dirty]" otherwise, so a build between
// releases is still identifiable from the in-headset menu (see
// MenuOverlay.java's header, which reads BuildConfig.VERSION_NAME).
def gitDescribeVersionName = {
try {
def proc = ["git", "-C", projectDir.toString(), "describe", "--tags", "--always", "--dirty"].execute()
proc.waitFor()
if (proc.exitValue() == 0) {
def out = proc.text.trim()
if (out) {
return out
}
}
} catch (Exception ignored) {
// git not on PATH, or this isn't a git checkout at all (e.g. a
// source tarball) - fall through to the placeholder below.
}
return "0.0.0-dev"
}
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : gitDescribeVersionName()
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
// prebuilt libraries (see build-image/prepare-android-project.sh
// --host-paths, and the engineDir/gl4esDir/prebuiltDir properties read
// above) so a plain Android Studio build/run can never silently compile
// against a stale scratch copy after engine/, android/engine-patches/,
// android/gl4es-src/, or android/gl4es-patches/ change - previously a
// manual, easy to forget re-run. inputs/outputs are declared so Gradle
// skips the (Docker-invoking, not free) step entirely when nothing
// relevant actually changed, keeping pure-Java edit/run cycles fast.
//
// This step only re-stages whatever is already baked into the build-image
// (it re-runs prepare-android-project.sh inside the existing image, which
// is fast - no compilation happens here) - engine/ and gl4es-src/ are both
// then compiled from these scratch copies by the externalNativeBuild CMake
// configure below (see android/engine-patches/02-android-opengl-es.patch's
// add_subdirectory of gl4esDir), so Gradle's own incremental CMake/Ninja
// build only recompiles what actually changed, same as any other native
// source edit. This task does NOT rebuild the image itself: if you changed
// build-image/Dockerfile (a real toolchain/dependency change, not
// engine-patches/ or gl4es-patches/, which this task re-stages on its own),
// you still need to run ./build-image.sh yourself first (a slow, deliberate
// step - see the memory on build-image versioning) so the image actually
// contains your changes before this task re-stages them.
def repoRoot = file("${projectDir}/../..")
def stageEngineTask = tasks.register("stageEngine", Exec) {
group = "build setup"
description = "Refreshes the patched engine/ scratch copy and prebuilt libraries for a native Android Studio build (build-image/prepare-android-project.sh --host-paths)."
workingDir repoRoot
commandLine "./run-image.sh", "bash", "build-image/prepare-android-project.sh", "--host-paths"
// /opt/prebuilt/android only ever exists inside the build-image
// container itself (baked in at image-build time, never on the host -
// see build-image/Dockerfile). Its presence means this build is
// build-apk.sh's own gradlew call, running INSIDE that container, which
// already staged everything itself in container mode before invoking
// gradlew - re-running this task there would try to `docker run` from
// inside a container with no docker socket, breaking make apk/CI. Only
// a genuine host-side Android Studio build (where this path is absent)
// needs this task.
onlyIf { !file('/opt/prebuilt/android').isDirectory() }
inputs.dir("${repoRoot}/engine")
inputs.dir("${projectDir}/../engine-patches")
inputs.dir("${projectDir}/../gl4es-src")
inputs.dir("${projectDir}/../gl4es-patches")
inputs.file("${repoRoot}/build-image/Dockerfile")
// Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json")
inputs.dir("${repoRoot}/res/controller_profiles")
inputs.file("${repoRoot}/res/splash.png")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt")
outputs.file("${projectDir}/../engine.properties")
outputs.dir("${projectDir}/src/main/assets")
}
// preBuild is what every variant's compile/native-build tasks already
// transitively depend on, regardless of AGP version's exact CMake task
// naming - the simplest reliable hook to run before any of them.
afterEvaluate {
tasks.named("preBuild").configure {
dependsOn stageEngineTask
}
}
android {
namespace "de.ladkau.questshock"
// compileSdk/buildToolsVersion/ndkVersion must all match what
// build-image/Dockerfile actually installs - otherwise AGP defaults to
// whatever version it itself prefers and tries to download it into the
// SDK dir at build time, which isn't writable.
compileSdk 34
buildToolsVersion "34.0.0"
ndkVersion "26.1.10909125"
defaultConfig {
applicationId "de.ladkau.questshock"
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
// comment on ANDROID_PLATFORM_VERSION: paired with
// requestLegacyExternalStorage in the manifest, this keeps
// /sdcard/QuestShock/ a plain, unrestricted shared folder instead of
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
// targets - not just whatever's the current API level today.
minSdkVersion 24
targetSdkVersion 29
versionCode questshockVersionCode
versionName questshockVersionName
externalNativeBuild {
cmake {
// engine/CMakeLists.txt's own dependency-selection options
// (see engine/CMakeLists.txt near the top): SDL2 via
// find_package (our Android SDL2 build installs a real CMake
// config, unlike the desktop build's older bundled copy);
// SDL2_mixer and FluidSynth via the same build_ext/ BUNDLED
// convention the desktop build already uses (populated in the
// scratch engine copy by prepare-android-project.sh from
// prebuiltDir). ANDROID_GL4ES_DIR is read by
// android/engine-patches/02-android-opengl-es.patch, which
// add_subdirectory()'s it to compile gl4es from source as
// part of this same CMake configure, instead of linking a
// prebuilt .so - so gl4es-patches/ changes just need a normal
// rebuild here, not a build-image rebuild.
// The CMAKE_FIND_ROOT_PATH* overrides below are needed
// because the NDK toolchain file restricts find_package/
// find_path/find_library to its own sysroot by default,
// which would otherwise miss our custom-installed SDL2 (see
// build-image/Dockerfile's SDL2_mixer build, which hit the
// exact same thing).
// -Wl,-z,max-page-size=16384: NDK 26 doesn't 16 KB-align ELF
// LOAD segments by default (only automatic in NDK 28+) - see
// build-image/Dockerfile's ANDROID_16KB_LDFLAGS, which does
// the same for the prebuilt SDL2/SDL2_mixer/fluidsynth-lite
// .so's this links against (gl4es is compiled here with the
// same flag passed straight through, see its add_subdirectory
// above).
// ANDROID_EXTRA_SOURCES is a semicolon-separated CMake list,
// not shell-split - AGP passes each "arguments" string
// straight through as one argv element to cmake, so the
// ';' below survives intact. ANDROID_EXTRA_INCLUDE_DIRS lets
// engine/'s own OpenGL.cc find "xr_session.h" (see
// android/engine-patches/10-android-openxr-cmake.patch,
// which adds it to include_directories()).
// USE_ANDROID_LOG/STATICLIB used to be passed to gl4es's own,
// separate cmake invocation in build-image/Dockerfile - now
// that it's add_subdirectory()'d into this same configure,
// they need to be set here instead, or gl4es silently falls
// back to stdio logging, which never reaches logcat. ANDROID
// itself doesn't need setting here - the NDK toolchain file
// already defines it before any CMakeLists.txt runs.
arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \
"-DANDROID_PREBUILT_DIR=${prebuiltDir}", \
"-DANDROID_GL4ES_DIR=${gl4esDir}", \
"-DUSE_ANDROID_LOG=ON", "-DSTATICLIB=OFF", \
"-DANDROID_EXTRA_INCLUDE_DIRS=${projectDir}/src/main/cpp", \
"-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \
"-DCMAKE_FIND_ROOT_PATH=${prebuiltDir}/sdl2", \
"-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c;${projectDir}/src/main/cpp/qs_splash.c"
abiFilters 'arm64-v8a'
}
}
}
externalNativeBuild {
cmake {
path file("${engineDir}/CMakeLists.txt")
}
}
// The release key comes from the environment (build-image/build-apk.sh,
// CI secrets). Without it there is no release signing, and the release
// build type is left unsigned.
def releaseKeystore = System.getenv("QUESTSHOCK_KEYSTORE")
def releaseStorePassword = System.getenv("QUESTSHOCK_KEYSTORE_PASSWORD")
signingConfigs {
release {
if (releaseKeystore) {
storeFile file(releaseKeystore)
storePassword releaseStorePassword
keyAlias System.getenv("QUESTSHOCK_KEY_ALIAS") ?: "questshock"
keyPassword System.getenv("QUESTSHOCK_KEY_PASSWORD") ?: releaseStorePassword
}
}
}
buildTypes {
release {
minifyEnabled false
if (releaseKeystore) {
signingConfig signingConfigs.release
}
}
}
// Generates BuildConfig (specifically BuildConfig.VERSION_NAME, read by
// MenuOverlay.java's header) - off by default since AGP 8.0, so this
// has to be requested explicitly.
buildFeatures {
buildConfig true
}
lint {
abortOnError false
}
}
dependencies {
// ActivityCompat.checkSelfPermission/requestPermissions in
// QuestShockActivity - the plain android.app.Activity APIs work the
// same way without androidx, but ActivityCompat is the standard,
// documented way to request runtime permissions safely.
implementation 'androidx.core:core:1.12.0'
}
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto">
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<!-- External mouse input events -->
<uses-feature android:name="android.hardware.type.pc" android:required="false" />
<!-- Immersive OpenXR app now (see android/app/src/main/cpp/xr_session.c),
not a 2D Home panel - vr.headtracking is what actually declares that
to Horizon OS. -->
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
<!-- Plain, unrestricted /sdcard access for the res/data,res/sound the
user drops in and the shaders/soundfont this app extracts there on
first run - see res/GET_ASSETS_QUEST.txt and QuestShockActivity.
requestLegacyExternalStorage below (paired with targetSdkVersion 29
in build.gradle) is what keeps this working instead of Android 11+
scoped storage. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:requestLegacyExternalStorage="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
android:hardwareAccelerated="true" >
<!-- Tells Horizon OS this activity wants the compositor for itself
(immersive VR), not the Home 2D-panel path - required for an
OpenXR session to actually start. -->
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
runtimes ignore it. -->
<meta-data android:name="com.oculus.supportedDevices"
android:value="quest2|questpro|quest3|quest3s" />
<activity android:name="de.ladkau.questshock.QuestShockActivity"
android:label="@string/app_name"
android:alwaysRetainTaskState="true"
android:launchMode="singleInstance"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:preferMinimalPostProcessing="true"
android:screenOrientation="landscape"
android:exported="true"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- The vendor-neutral, Khronos-defined way to declare an
immersive OpenXR entry point, plus Meta's own VR
category, which Horizon OS looks for to treat the app as
a VR app. Other runtimes ignore the Meta one. -->
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
#ifndef QUESTSHOCK_QS_CONFIG_H
#define QUESTSHOCK_QS_CONFIG_H
#include <SDL.h>
#ifdef __cplusplus
extern "C" {
#endif
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
// root for the documented schema. Lives at a plain relative path
// ("questshock.config.json") since the whole engine already runs with its
// cwd chdir()'d to the user-facing /sdcard/QuestShock/ folder (see
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
//
// Deliberately independent of prefs.txt: this file is never read or written
// as a side effect of prefs.txt load/save, and vice versa.
// Parses the config file if it exists (silent no-op if it doesn't - this
// never creates the file itself) and applies every recognized option
// directly into gShockPrefs/mode_id. Call once, early in main() - see
// android/engine-patches/21-android-config.patch - after LoadPrefs() (so the
// config file can override prefs.txt) and before init_all() (so there's no
// live-vs-restart distinction to worry about: nothing is initialized yet, so
// every field takes full effect as part of this same startup).
void qs_config_load_startup(void);
// Called from the "Save Configuration"/"Load Configuration" button JNI
// handlers (questshock_native.c), which run on Android's UI thread. These
// only enqueue a custom SDL event - the actual file I/O and any
// gShockPrefs/mode_id/message_info() work happens on the engine's own SDL
// main thread instead (see qs_config_handle_event() below), matching this
// codebase's existing convention for crossing this exact thread boundary
// (xr_session.c's xr_poll_events(), questshock_native.c's SDL_TEXTINPUT
// push for keyboard input).
void qs_config_request_save(void);
void qs_config_request_load(void);
// Same cross-thread-dispatch reasoning as qs_config_request_save()/_load()
// above - called from the Video Options tab's controls (MenuOverlay.java),
// which are just as much on Android's UI thread as the Save/Load buttons.
// opengl=true/false selects "opengl"/"software"; resolution_index is 0-4,
// indexing the same table qs_config_current_resolution_index() below
// reads. Applies live immediately (no restart involved - see CONFIG.md);
// unlike Load Configuration, these don't post a message_info() HUD
// notification for every single tap - the tab's own selection highlight is
// the feedback.
void qs_config_request_set_renderer(bool opengl);
void qs_config_request_set_resolution(int resolution_index);
// Same cross-thread-dispatch reasoning as the two above - called from the
// Main Menu tab's Language row (MenuOverlay.java). language_index is 0
// English/1 French/2 German (gShockPrefs.goLanguage's own numbering,
// Prefs.h). Applies live immediately, the same way the game's own options
// menu applies a language change (see qs_config.c's qs_apply_language()).
void qs_config_request_set_language(int language_index);
// Same cross-thread dispatch as the three above - called from the Main Menu
// tab's "Reset Position" button. Re-anchors the player's position in front of
// the game quad (see xr_input_reset_player_position()); never reads or writes
// the config file.
void qs_config_request_reset_position(void);
// Same cross-thread-dispatch reasoning as the four above - called from the
// Video Options tab's "Initial Screen"/"Game Screen" width/distance
// preset rows (MenuOverlay.java). preset_index indexes qs_config.c's own
// shared, offset+step-generated width/distance preset lists (both in
// meters; the offset/step themselves are config-file-only, not exposed
// as buttons), read back via the four qs_config_current_*_screen_*_index()
// getters below. Applies live immediately if the just-changed profile is
// the currently active one (see CONFIG.md) - otherwise just updates the
// stored preference, invisibly, until that profile becomes active (e.g.
// tweaking "Initial Screen" mid-game).
void qs_config_request_set_initial_screen_width(int preset_index);
void qs_config_request_set_initial_screen_distance(int preset_index);
void qs_config_request_set_game_screen_width(int preset_index);
void qs_config_request_set_game_screen_distance(int preset_index);
// Cross-thread requests from the Controller Options tab (MenuOverlay.java),
// enqueued like every request_*() above. action_index indexes
// xr_input_action_name()'s list. Begin/cancel start and stop rebind capture;
// reset restores all bindings (the built-in defaults, then
// questshock.config.default.json's player_controls group if it has one).
void qs_config_request_begin_control_capture(int action_index);
void qs_config_request_cancel_control_capture(void);
void qs_config_request_reset_player_controls(void);
// Called from xr_input.c's xr_input_process_capture() on the engine's own
// thread, so no cross-thread dispatch is needed (as with
// qs_screen_profile_notify_loop_mode() below). Resolves
// MenuOverlay.onControlCaptureResult(int, String) for action_index (raw_name
// NULL on timeout or cancel), and again for displaced_action_index if the new
// binding took the input from another action (-1/NULL when none), so both
// rows can be updated together.
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
int displaced_action_index,
const char *displaced_raw_name);
// Called from xr_input.c whenever the VR menu's visibility changes. Showing
// the menu pauses a running game (not the main menu, intro or cutscenes) that
// is not already paused; hiding it unpauses only if the menu did the pausing.
// Enqueued like the request_*() functions above; the pause itself runs on the
// engine's thread at pump_events() time.
void qs_config_notify_menu_visibility(bool visible);
// Cheats tab (MenuOverlay.java). Same enqueue-only requests as above; the work
// runs on the engine's thread. cheat_id: 0 give everything, 1 toggle physics,
// 2 next level, 3 previous level, 4-7 toggle unlimited health, energy, ammo,
// items (grenades and drugs). Cheats need a running game (the unlimited
// switches can be set anywhere). The getters are plain scalar reads, safe from
// the UI thread; the unlimited mask has bit 0 health, 1 energy, 2 ammo, 3 items.
void qs_config_request_cheat(int cheat_id);
int qs_config_cheat_unlimited_mask(void);
bool qs_config_cheat_physics_running(void);
// Called once per frame from xr_input.c on the engine's thread; applies the
// unlimited-resource cheats that are switched on.
void qs_config_frame_tick(void);
// Called from xr_input.c's xr_input_suggest_bindings() when the OpenXR runtime
// rejects a supported interaction profile's suggested bindings at startup.
// That is an app bug, not a config problem, so it shows
// MenuOverlay.showFatalControllerBindingError()'s non-cancelable "Exit" dialog;
// xr_input_init() aborts right after.
void qs_config_report_fatal_input_error(const char *profile_display_name);
// Returns true and handles the event if it is one of the custom events the
// request_*()/notify functions above enqueue. Called from pump_events()
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
// engine's thread, once per polled event. Returns false for any other event
// type, including before the custom types are registered.
bool qs_config_handle_event(const SDL_Event *event);
// Called directly (no event-queue dispatch - see
// android/engine-patches/22-android-screen-profile.patch and this
// function's own definition in qs_config.c for why) from the engine's
// mainloop.c whenever the loop mode changes, and once from
// qs_config_load_startup() - picks between the "Initial Screen" (menu/
// intro) and "Game Screen" (actual gameplay) presets based on new_mode
// (mainloop.h's SETUP_LOOP/CUTSCENE_LOOP vs. everything else) and applies
// the newly-active one's shape to xr_session.c, but only if it's actually
// different from what was already active.
void qs_screen_profile_notify_loop_mode(short new_mode);
// Read-only queries for the Video Options tab's own selection highlight
// (MenuOverlay.java re-reads these whenever the tab is (re)shown, so it
// reflects the live value even if it was last changed some other way, e.g.
// the Ctrl+G hotkey or Load Configuration). Safe to call directly from
// Android's UI thread despite not going through the event-queue dispatch
// the mutating calls above use - same low-risk reasoning already used for
// Save Configuration's own reads of gShockPrefs/mode_id: these are simple,
// independent POD scalar reads, not a sequence that needs to be atomic
// with anything else.
bool qs_config_current_renderer_is_opengl(void);
int qs_config_current_resolution_index(void);
// -1 (not just an unlikely fallback, a routine, expected result here -
// unlike qs_config_current_resolution_index() above) if the profile's
// current value doesn't exactly match one of qs_config.c's own
// offset+step-generated width/distance presets, e.g. after a hand-edited
// config file sets an in-between value - MenuOverlay.java's own
// setSelected() naturally shows no button selected in that case, no
// special-casing needed.
int qs_config_current_initial_screen_width_index(void);
int qs_config_current_initial_screen_distance_index(void);
int qs_config_current_game_screen_width_index(void);
int qs_config_current_game_screen_distance_index(void);
// Returns the value preset button `index` (0..4) currently shows for the
// width/distance option - shared by both "Initial Screen" and "Game
// Screen" columns (their offset/step is config-file-only, not per-profile
// - see qs_config.c's own comment on the underlying statics), so
// MenuOverlay.java can label its preset buttons correctly even after a
// Load Configuration changes the offset/step. Index is clamped into
// range, not validated - every index 0..4 always has a value.
float qs_config_screen_width_preset_value(int index);
float qs_config_screen_distance_preset_value(int index);
// gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by
// MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer
// to pick which language's chrome text (Strings.java) to display, so a
// language change made mid-session via the game's own options menu (or our
// own Language row, or Load Configuration) is picked up without needing to
// reopen either panel; also doubles as the Main Menu tab's Language row's
// own selection highlight, same as qs_config_current_renderer_is_opengl()/
// qs_config_current_resolution_index() above. Same off-thread-scalar-read
// reasoning as those two getters.
int qs_config_current_language(void);
#ifdef __cplusplus
}
#endif
#endif // QUESTSHOCK_QS_CONFIG_H
+110
View File
@@ -0,0 +1,110 @@
#include "qs_splash.h"
#include <SDL.h>
#include <android/log.h>
#include <stdbool.h>
#include <string.h>
#include "xr_session.h"
#define QS_SPLASH_HOLD_MS 2500
#define QS_SPLASH_FADE_MS 500
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
// The XR session only starts once frames are polled, so the display time is
// counted from the first submitted frame; this bounds the wait for it.
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
extern void pump_events(void);
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
extern void android_composite_software_frame(SDL_Surface *ui);
static SDL_Surface *s_image = NULL;
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
if (s_image != NULL) {
SDL_FreeSurface(s_image);
s_image = NULL;
}
if (argb == NULL || width <= 0 || height <= 0)
return;
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
if (s_image == NULL)
return;
for (int y = 0; y < height; y++)
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
(size_t)width * sizeof(uint32_t));
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
}
// 0-255 brightness at `elapsed` ms after the first frame.
static int qs_splash_level(uint32_t elapsed) {
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
if (edge >= QS_SPLASH_FADE_MS)
return 255;
return (int)(255 * edge / QS_SPLASH_FADE_MS);
}
void qs_splash_show(void) {
if (s_image == NULL) {
LOGI("Startup splash: no image");
return;
}
int width, height;
xr_get_game_swapchain_size(&width, &height);
if (width <= 0 || height <= 0) {
LOGI("Startup splash: no XR game swapchain");
return;
}
// The image is stretched over the whole swapchain image, so it is
// letterboxed into a canvas of that size to keep its aspect ratio.
SDL_Surface *canvas =
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
if (canvas == NULL)
return;
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
SDL_Rect dst;
dst.w = (int)(s_image->w * scale);
dst.h = (int)(s_image->h * scale);
dst.x = (width - dst.w) / 2;
dst.y = (height - dst.h) / 2;
const uint32_t waitStart = SDL_GetTicks();
uint32_t shownStart = 0;
uint32_t elapsed = 0;
int drawnLevel = -1;
bool done = false;
while (!done) {
pump_events();
xr_poll_events();
if (xr_frame_begin()) {
if (shownStart == 0)
shownStart = SDL_GetTicks();
elapsed = SDL_GetTicks() - shownStart;
if (elapsed >= QS_SPLASH_TOTAL_MS) {
elapsed = QS_SPLASH_TOTAL_MS;
done = true;
}
const int level = qs_splash_level(elapsed);
if (level != drawnLevel) {
drawnLevel = level;
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
SDL_SetSurfaceColorMod(s_image, level, level, level);
SDL_BlitScaled(s_image, NULL, canvas, &dst);
}
android_composite_software_frame(canvas);
} else {
SDL_Delay(10);
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
done = true;
}
xr_frame_end();
}
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
(unsigned)(SDL_GetTicks() - waitStart));
SDL_FreeSurface(canvas);
}
+15
View File
@@ -0,0 +1,15 @@
// Startup splash: the QuestShock title, shown on the game screen before the
// engine's own splash sequence.
#pragma once
#include <stdint.h>
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
// Called from JNI before the engine starts; replaces any earlier image. Without
// an image qs_splash_show() does nothing.
void qs_splash_set_image(const uint32_t *argb, int width, int height);
// Engine thread only (called from engine patch 27). Fades the image in and out
// on the game screen and returns when done, or after a few seconds if the XR
// session never starts.
void qs_splash_show(void);
@@ -0,0 +1,298 @@
// android.system.Os has no public chdir() (confirmed against the actual
// API 34 stub jar - Java has no way to change a process's working
// directory at all). This is the one bit of native code QuestShockActivity
// 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 <ctype.h>
#include <jni.h>
#include <unistd.h>
#include <SDL.h>
#include "qs_config.h"
#include "qs_splash.h"
#include "xr_input.h"
#include "xr_overlay.h"
#include "xr_session.h"
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
chdir(cpath);
(*env)->ReleaseStringUTFChars(env, path, cpath);
}
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
if (data == NULL)
return;
qs_splash_set_image((const uint32_t *)data, width, height);
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
}
// KeyboardOverlay's hand-built on-screen keyboard (see
// 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_nativeSendPrintableCharDown(JNIEnv *env, jclass clazz,
jchar c) {
SDL_Event event;
SDL_zero(event);
event.type = SDL_TEXTINPUT;
event.text.timestamp = SDL_GetTicks();
event.text.windowID = 0;
event.text.text[0] = (char)c;
event.text.text[1] = '\0';
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
// button only ever affects the menu launcher.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_KeyboardOverlay_nativeRequestClose(JNIEnv *env, jclass clazz) {
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), false);
}
// MenuOverlay's "Keyboard" button - only opens the keyboard overlay, never
// touches the menu launcher's own visibility, so both can be shown
// together.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true);
}
// MenuOverlay's "Save Configuration"/"Load Configuration" buttons - see
// qs_config.h for why these only enqueue an event rather than doing the
// actual file/engine-state work here directly (this runs on Android's UI
// thread, not the engine's own).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSaveConfig(JNIEnv *env, jclass clazz) {
qs_config_request_save();
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) {
qs_config_request_load();
}
// MenuOverlay's Video Options tab - live-appliable controls, see
// qs_config.h. Same enqueue-only reasoning as the Save/Load buttons above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetRenderer(JNIEnv *env, jclass clazz,
jboolean opengl) {
qs_config_request_set_renderer(opengl);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jclass clazz,
jint resolutionIndex) {
qs_config_request_set_resolution(resolutionIndex);
}
// MenuOverlay's Main Menu tab's Language row - same enqueue-only reasoning as above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetLanguage(JNIEnv *env, jclass clazz,
jint languageIndex) {
qs_config_request_set_language(languageIndex);
}
// MenuOverlay's Main Menu tab's "Reset Position" button - same enqueue-only
// reasoning as above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jclass clazz) {
qs_config_request_reset_position();
}
// MenuOverlay's Video Options tab - "Initial Screen"/"Game Screen" width/
// distance preset rows, same enqueue-only reasoning as above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenWidth(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_initial_screen_width(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenWidth(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_game_screen_width(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenDistance(JNIEnv *env,
jclass clazz,
jint presetIndex) {
qs_config_request_set_initial_screen_distance(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenDistance(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_game_screen_distance(presetIndex);
}
// Read-only queries - safe to call directly (no enqueue needed, see
// qs_config.h's own comment on each of these).
JNIEXPORT jboolean JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetRendererIsOpenGl(JNIEnv *env, jclass clazz) {
return qs_config_current_renderer_is_opengl();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_resolution_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenWidthIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_initial_screen_width_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenWidthIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_game_screen_width_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenDistanceIndex(JNIEnv *env,
jclass clazz) {
return qs_config_current_initial_screen_distance_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenDistanceIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_game_screen_distance_index();
}
// Preset button *labels* (not the current value) - shared by both
// "Initial Screen" and "Game Screen" columns, since their offset/step is
// config-file-only, not per-profile (see qs_config.h's own comment).
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenWidthPresetValue(JNIEnv *env, jclass clazz,
jint index) {
return qs_config_screen_width_preset_value(index);
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenDistancePresetValue(JNIEnv *env, jclass clazz,
jint index) {
return qs_config_screen_distance_preset_value(index);
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) {
return qs_config_current_language();
}
// MenuOverlay's header - polled on a periodic timer (see its own comment),
// not event-driven, so these are plain synchronous reads too.
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetFps(JNIEnv *env, jclass clazz) {
return xr_session_get_fps();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetFrameTimeMs(JNIEnv *env, jclass clazz) {
return xr_session_get_frame_ms();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_cpu_utilization();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_gpu_utilization();
}
// Controller Options tab: actionIndex follows xr_input.c's action order
// (MenuOverlay.java and Strings.java hold the matching labels).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestBeginControlCapture(JNIEnv *env, jclass clazz,
jint actionIndex) {
qs_config_request_begin_control_capture(actionIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCancelControlCapture(JNIEnv *env, jclass clazz) {
qs_config_request_cancel_control_capture();
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPlayerControls(JNIEnv *env, jclass clazz) {
qs_config_request_reset_player_controls();
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetControlBindingKey(JNIEnv *env, jclass clazz,
jint actionIndex) {
return (*env)->NewStringUTF(env, xr_input_get_action_binding_name(actionIndex));
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetActiveInteractionProfilePath(JNIEnv *env,
jclass clazz) {
return (*env)->NewStringUTF(env, xr_input_get_active_interaction_profile_path());
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeSetControllerOptionsTabOpen(JNIEnv *env, jclass clazz,
jboolean open) {
xr_input_set_controller_options_tab_open(open == JNI_TRUE);
}
// Cheats tab: same enqueue-only pattern as the other requests.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCheat(JNIEnv *env, jclass clazz, jint cheatId) {
qs_config_request_cheat(cheatId);
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatUnlimitedMask(JNIEnv *env, jclass clazz) {
return qs_config_cheat_unlimited_mask();
}
JNIEXPORT jboolean JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatPhysicsRunning(JNIEnv *env, jclass clazz) {
return qs_config_cheat_physics_running();
}
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
// Controller input for questshock's immersive Quest build: one OpenXR
// action set (aim pose + trigger click per hand, a menu-toggle button on
// the left controller) plus ray/quad hit-testing. Each hand's ray is
// tried against the keyboard overlay, then the menu overlay (see
// xr_overlay.h, xr_session.c), then - only if neither actually claims it
// this frame (landing on it now, or continuing a touch/drag begun on a
// previous frame - not merely because that overlay happens to be visible)
// - the game quad xr_session.c submits, forwarding trigger edges to
// whichever overlay claims the ray as synthetic touches. The game quad
// itself acts as a plain desktop-style mouse pointer into the engine's
// own UI (see xr_mouse.h) - absolute cursor position while the ray hits
// it, plus a left click on the trigger edge; a pending click there
// likewise keeps claiming the ray ahead of the keyboard/menu until
// released, so the eventual mouse-up isn't lost if the ray strays. Since
// none of the three targets' own feedback (the overlays' on-quad cursor,
// drawn by their Java views; the engine's own mouse cursor sprite for the
// game quad) gives any indication while the ray is short of actually
// landing on something, every claimed target also builds a thin,
// billboarded laser-beam quad per hand from the controller to wherever
// the ray currently crosses that target's plane (see
// xr_input_get_beam_layer()) - so aiming looks and behaves the same
// whether the target is an overlay or the game quad itself.
#ifndef QUESTSHOCK_XR_INPUT_H
#define QUESTSHOCK_XR_INPUT_H
#include <stdbool.h>
#include <stdint.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
// Call once, right after the session is created (see xr_session.c's
// xr_create_instance_and_session()) - creates the action set/actions,
// suggests Touch controller bindings, creates the per-hand aim action
// spaces and a view-space reference (for the laser-beam billboard math),
// creates the two per-hand beam swapchains (swapchain_format - the same
// format shared by the game swapchain and both overlays), and attaches
// the action set to the session. Returns false (logged, non-fatal - the
// caller keeps rendering without input) if any of that fails.
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format);
// Call once per frame from xr_frame_end(), before releasing the acquired
// swapchain image - syncs this frame's action states (always, so edge
// detection stays correct even on frames with nothing to draw), handles
// the menu_toggle button's edge, and hit-tests/dispatches each hand's ray
// against keyboard/menu/game quad in that priority order (see the file
// comment above). draw gates only the laser-beam visuals (and the
// head-pose locate that feeds them) - if false (nothing to draw into this
// frame, e.g. no swapchain image was acquired), hit-testing and
// touch-forwarding still run. baseSpace/time must match whatever
// xr_frame_begin() used to predict this frame.
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// Call once per hand (hand: 0=left, 1=right) after xr_input_sync_and_draw()
// in the same frame - if that hand's ray claimed the keyboard, menu, or
// game quad this frame (see the file comment above), fills *out_quad's
// subImage/pose/size for its laser-beam ribbon and returns true.
// space/eyeVisibility/layerFlags are left for the caller to set, same
// convention as xr_overlay_render_and_build_layer(). Returns false
// (out_quad untouched) if no beam should be shown for that hand this
// frame.
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
// Recenters the player so the game quad ("virtual screen") appears directly
// ahead of wherever they are standing and facing, from their current real head
// pose. Never reads or writes questshock.config.json. Called automatically
// once, from xr_input_sync_and_draw(), on the first frame with a valid head
// pose after startup, unless a complete saved player_position was loaded (see
// xr_input_skip_startup_recenter()): a saved position is relative to a
// previous session's real-space origin, which is not stable across launches.
// Returns false (no-op) if no valid head pose is available yet.
bool xr_input_recenter_player_to_screen(void);
// The Main Menu tab's "Reset Position" button (via qs_config.c). Like
// xr_input_recenter_player_to_screen(), but re-anchors only the player's
// position to the centre in front of the game quad; the current facing (yaw)
// is kept, so the screen does not rotate to follow the gaze. Returns false
// (no-op) if no valid head pose is available yet.
bool xr_input_reset_player_position(void);
// Called once, from qs_config_load_startup() (qs_config.c), when a
// complete saved player_position was actually loaded from
// questshock.config.json - cancels the pending automatic startup recenter
// above so it doesn't immediately override the value that was just
// applied. Safe to call regardless of whether xr_input_init() has even run
// yet (this only touches the plain file-local flag, no XR calls).
void xr_input_skip_startup_recenter(void);
// ===========================================================================
// Remappable controls ("player_controls" config group; see qs_config.c and
// CONFIG.md). menu_toggle is not among them and has no getter/setter: it is
// fixed (see xr_input_sync_and_draw()).
// ===========================================================================
// Number of remappable actions and each one's index<->name mapping, which
// qs_config.c uses to parse/write the player_controls group without its own
// copy of the list. The names are the keys used in questshock.config.json;
// index_from_name() returns false for an unrecognized name.
int xr_input_action_count(void);
const char *xr_input_action_name(int action_index);
bool xr_input_action_index_from_name(const char *name, int *out_index);
// Current binding for one action, as an opaque int and as its string form
// (e.g. "right_b", "trigger", "none" for unbound), the same strings used in
// questshock.config.json and reported by capture.
int xr_input_get_action_binding(int action_index);
const char *xr_input_get_action_binding_name(int action_index);
// Sets one action's binding from a config-file string. Returns false (binding
// unchanged) for an unrecognized name, or for the symmetric "trigger"/
// "squeeze" on an action other than mouse_click/mouse_double_click. Does not
// enforce one-action-per-input the way capture does: a hand-edited file that
// binds two actions to one input is accepted as-is.
bool xr_input_set_action_binding(int action_index, const char *raw_name);
// Restores every action to its built-in default (the table xr_input_init()
// starts from). qs_config.c calls this first for "Reset to Defaults", then
// applies questshock.config.default.json's player_controls group on top if it
// has one.
void xr_input_reset_player_controls(void);
// Begins/cancels rebind capture for one action (an index into
// xr_input_action_name()'s list): the next fresh button or thumbstick-direction
// edge on any hand becomes that action's binding and is reported via
// qs_config_notify_control_capture_result(); with no press, capture cancels
// after 8 seconds. Called via qs_config.c's cross-thread request functions.
// The menu button is never captured.
void xr_input_capture_begin(int action_index);
void xr_input_capture_cancel(void);
// The OpenXR interaction profile currently bound (e.g.
// "/interaction_profiles/oculus/touch_controller"), refreshed once per frame.
// MenuOverlay.java's Controller Options tab uses it to pick a file from
// controller_profiles/ (see CONFIG.md). Empty until the first successful
// query.
const char *xr_input_get_active_interaction_profile_path(void);
// Set by MenuOverlay.java when the Controller Options tab becomes or stops
// being the menu's current tab. While it is open, the menu's thumbstick
// locomotion (snap turn/smooth move) is disabled.
void xr_input_set_controller_options_tab_open(bool open);
void xr_input_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
+20
View File
@@ -0,0 +1,20 @@
#include "xr_mouse.h"
#include <SDL.h>
#include "mouse.h" // engine/src/Libraries/INPUT/Source/mouse.h - mouse_put_xy()
void xr_mouse_move(int x, int y) { mouse_put_xy((short)x, (short)y); }
void xr_mouse_click(bool down) {
SDL_Event event;
SDL_zero(event);
event.type = down ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP;
event.button.timestamp = SDL_GetTicks();
event.button.windowID = 0;
event.button.which = 0;
event.button.button = SDL_BUTTON_LEFT;
event.button.state = down ? SDL_PRESSED : SDL_RELEASED;
event.button.clicks = 1;
SDL_PushEvent(&event);
}
+36
View File
@@ -0,0 +1,36 @@
// Bridges VR aim-ray hit-testing (xr_input.c) to the vendored engine's
// mouse input (engine/src/Libraries/INPUT/Source/mouse.h) - lets the
// controller's laser pointer drive the game's own UI (inventory, menus,
// dialogs) exactly like a desktop mouse: absolute cursor position plus a
// single left button, no relative/mouselook mode, no right button.
#ifndef QUESTSHOCK_XR_MOUSE_H
#define QUESTSHOCK_XR_MOUSE_H
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Sets the engine's absolute mouse position (mouse_put_xy()) - call every
// frame the aim ray is actually hitting the game quad; x/y are pixel
// coordinates in the game's own logical resolution (see
// xr_get_game_resolution() in xr_session.h), already clamped by the
// caller. Do not call on frames the ray isn't hitting the game quad - the
// cursor should hold its last position, not snap elsewhere.
void xr_mouse_move(int x, int y);
// Pushes a synthetic SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) - matches
// 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.
void xr_mouse_click(bool down);
#ifdef __cplusplus
}
#endif
#endif
+503
View File
@@ -0,0 +1,503 @@
#include "xr_overlay.h"
#include <dlfcn.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <SDL.h>
#include "xr_swapchain.h"
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Same constant (deliberately not <math.h>'s M_PI, which isn't used
// anywhere else in this codebase and isn't guaranteed by the C standard -
// only POSIX) as xr_session.c's own play-space rebuild math.
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
struct XrOverlay {
XrInstance instance; // for error logging only
int width, height;
float distance_m, half_width_m, half_height_m;
float center_x_m, center_y_m;
// The *authored* distance_m (config->distance_m at creation time),
// never touched again afterward by anything, including
// xr_overlay_set_pose() - kept separate from the live, mutable
// distance_m above because that one stops meaning "how far in front of
// the origin" once a rotated set_pose() call (the menu recenter)
// repurposes it as a raw base-space Z coordinate; reusing *that* as a
// "how far in front of me" magnitude on a later recenter would drift
// further with every reopen instead of staying at a stable distance -
// see xr_overlay_get_default_distance_m().
float default_distance_m;
// Rotation about the vertical axis, applied on top of center_x_m/
// center_y_m/distance_m (see xr_overlay_set_pose()) - 0 for every
// overlay except the menu launcher immediately after it's freshly
// reopened (xr_input.c re-centers it in front of wherever the player
// is currently facing, which needs this to keep the quad actually
// facing them instead of whatever direction it started out at).
float yaw_deg;
bool visible;
XrSwapchainState swapchain;
jclass java_class;
jmethodID native_init_method;
jmethodID take_pixels_method;
jmethodID dispatch_touch_method;
jmethodID update_cursor_method;
// This overlay's content lives in this texture, uploaded by
// xr_overlay_upload_source_texture_if_dirty() below. It's created
// through gl4es (glGenTextures/glBindTexture/glTexImage2D), like any
// other texture the engine itself creates, so ordinary
// glTexSubImage2D uploads against it work the normal way.
GLuint source_texture;
// A framebuffer with source_texture as its only color attachment, used
// as the read source for the blit in
// xr_overlay_render_and_build_layer() below. Created via the real
// (non-gl4es) GLES entry points below: that blit bypasses gl4es
// entirely (see xr_overlay_render_and_build_layer() for why), so its
// source framebuffer has to be a real GL object rather than one gl4es
// tracks.
GLuint source_fbo;
uint32_t pixel_generation;
uint32_t source_uploaded_generation;
uint8_t *pixel_cache; // width*height*4 bytes
};
// This file's other GL calls otherwise resolve to gl4es (the only GL
// symbol provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), fine for anything shared with the engine's
// own gl4es-routed rendering. But the source FBO's blit (see
// xr_overlay_render_and_build_layer()) needs the real driver directly:
// gl4es's fixed-pipeline emulation unconditionally substitutes its own
// shader onto any gl4es-routed draw call, which would silently replace an
// overlay's content with whatever the game itself last rendered. A
// framebuffer blit has no shader stage at all, so going through the real
// driver for it sidesteps the problem entirely - resolved once here and
// shared by every XrOverlay instance, since they're process-wide function
// pointers regardless of how many overlays exist.
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
GLbitfield, GLenum);
static PFNQSGETINTEGERV real_glGetIntegerv;
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
// functions above.
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
static bool g_real_gles_loaded = false;
static bool xr_overlay_load_real_gles(void) {
if (g_real_gles_loaded)
return true;
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: overlay dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
g_real_gles_loaded = real_glGetIntegerv && real_glGenFramebuffers &&
real_glDeleteFramebuffers && real_glBindFramebuffer &&
real_glFramebufferTexture2D && real_glBlitFramebuffer;
return g_real_gles_loaded;
}
// FindClass() from this thread (SDL's native thread, attached to the JVM
// via AttachCurrentThread rather than spawned from Java) resolves against
// the bootstrap classloader, which only knows framework classes - it can't
// see app classes like de.ladkau.questshock.KeyboardOverlay at all.
// Routing through the activity's own classloader is the standard,
// documented workaround.
static jclass xr_overlay_find_class(JNIEnv *env, jobject activity, const char *name) {
jclass activityClass = (*env)->GetObjectClass(env, activity);
jmethodID getClassLoader =
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;");
jstring className = (*env)->NewStringUTF(env, name);
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
(*env)->DeleteLocalRef(env, className);
return result;
}
static bool xr_overlay_init_jni(XrOverlay *overlay, const char *java_class_name) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: overlay - no JNIEnv/Activity from SDL");
return false;
}
jclass localClass = xr_overlay_find_class(env, activity, java_class_name);
if (localClass == NULL) {
LOGE("XR: overlay - could not find class %s", java_class_name);
return false;
}
overlay->java_class = (jclass)(*env)->NewGlobalRef(env, localClass);
overlay->native_init_method = (*env)->GetStaticMethodID(env, overlay->java_class, "nativeInit",
"(Landroid/app/Activity;)V");
overlay->take_pixels_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeTakePixelsIfDirty", "()[B");
overlay->dispatch_touch_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeDispatchTouch", "(FFZ)V");
overlay->update_cursor_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeUpdateCursor", "(FFZ)V");
if (!overlay->native_init_method || !overlay->take_pixels_method ||
!overlay->dispatch_touch_method || !overlay->update_cursor_method) {
LOGE("XR: overlay - could not resolve %s JNI methods", java_class_name);
return false;
}
(*env)->CallStaticVoidMethod(env, overlay->java_class, overlay->native_init_method, activity);
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
// the constructor throws (it runs its View measure/layout/draw calls
// on this native render thread rather than the Android UI thread, a
// plausible crash vector), the exception would otherwise be left
// silently pending and corrupt whatever JNI call runs next.
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: overlay %s.nativeInit() threw a pending Java exception:", java_class_name);
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return false;
}
return true;
}
static bool xr_overlay_create_source_texture(XrOverlay *overlay) {
glGenTextures(1, &overlay->source_texture);
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, overlay->width, overlay->height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, NULL);
GLint prevFbo = 0;
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
real_glGenFramebuffers(1, &overlay->source_fbo);
real_glBindFramebuffer(GL_FRAMEBUFFER, overlay->source_fbo);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
overlay->source_texture, 0);
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
return overlay->source_texture != 0;
}
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
const XrOverlayConfig *config) {
if (!xr_overlay_load_real_gles()) {
LOGE("XR: overlay couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
return NULL;
}
XrOverlay *overlay = (XrOverlay *)calloc(1, sizeof(XrOverlay));
if (overlay == NULL)
return NULL;
overlay->instance = instance;
overlay->width = config->width;
overlay->height = config->height;
overlay->distance_m = config->distance_m;
overlay->default_distance_m = config->distance_m;
overlay->half_width_m = config->width_m * 0.5f;
overlay->half_height_m = config->width_m * 0.5f * (float)config->height / (float)config->width;
overlay->center_x_m = config->default_center_x_m;
overlay->center_y_m = config->default_center_y_m;
if (!xr_swapchain_create(instance, session, swapchain_format, config->width, config->height,
&overlay->swapchain)) {
LOGE("XR: overlay %s swapchain setup failed", config->java_class_name);
free(overlay);
return NULL;
}
if (!xr_overlay_create_source_texture(overlay)) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
if (!xr_overlay_init_jni(overlay, config->java_class_name)) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
overlay->pixel_cache = (uint8_t *)malloc((size_t)config->width * config->height * 4);
if (overlay->pixel_cache == NULL) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
overlay->source_uploaded_generation = (uint32_t)-1;
LOGI("XR: overlay %s ready (%dx%d)", config->java_class_name, config->width, config->height);
return overlay;
}
void xr_overlay_destroy(XrOverlay *overlay) {
if (overlay == NULL)
return;
if (overlay->source_texture != 0)
glDeleteTextures(1, &overlay->source_texture);
if (overlay->source_fbo != 0 && real_glDeleteFramebuffers != NULL)
real_glDeleteFramebuffers(1, &overlay->source_fbo);
if (overlay->java_class != NULL) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env != NULL)
(*env)->DeleteGlobalRef(env, overlay->java_class);
}
xr_swapchain_destroy(&overlay->swapchain);
free(overlay->pixel_cache);
free(overlay);
}
// Every function below tolerates overlay == NULL (a no-op, or the
// obvious "not visible"/zeroed-extent default) - xr_overlay_create() can
// fail (e.g. its backing Java class couldn't be resolved), and unlike the
// single static-global menu quad this replaced, an overlay instance is
// otherwise a heap pointer callers (xr_session.c, questshock_native.c) can
// hold onto and use across frames without re-checking for failure every
// time.
void xr_overlay_toggle_visible(XrOverlay *overlay) {
if (overlay != NULL)
overlay->visible = !overlay->visible;
}
void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
if (overlay != NULL)
overlay->visible = visible;
}
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->visible; }
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg) {
if (overlay == NULL) {
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = *yaw_deg = 0.0f;
return;
}
*center_x_m = overlay->center_x_m;
*center_y_m = overlay->center_y_m;
*distance_m = overlay->distance_m;
*half_width_m = overlay->half_width_m;
*half_height_m = overlay->half_height_m;
*yaw_deg = overlay->yaw_deg;
}
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
if (overlay == NULL)
return;
overlay->center_x_m = center_x_m;
overlay->center_y_m = center_y_m;
}
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
float yaw_deg) {
if (overlay == NULL)
return;
overlay->center_x_m = center_x_m;
overlay->center_y_m = center_y_m;
overlay->distance_m = distance_m;
overlay->yaw_deg = yaw_deg;
}
float xr_overlay_get_default_distance_m(const XrOverlay *overlay) {
return overlay == NULL ? 0.0f : overlay->default_distance_m;
}
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
if (overlay == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
// Call*Method form - deliberately sidesteps relying on JNI
// implementations correctly un-doing C's float-to-double default
// argument promotion for varargs float parameters (a well-known JNI
// footgun; Android's ART handles it correctly, but there's no reason
// to depend on that when the jvalue form is unambiguous either way).
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)down;
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->dispatch_touch_method, args);
}
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible) {
if (overlay == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Same jvalue-form rationale as xr_overlay_touch() above.
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)visible;
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->update_cursor_method, args);
}
// Pulls the Java view's latest pixels (if it redrew since the last check)
// into pixel_cache and bumps pixel_generation - called once per rendered
// frame, before deciding whether source_texture needs a fresh upload.
static void xr_overlay_refresh_pixel_cache(XrOverlay *overlay) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
jbyteArray pixels = (jbyteArray)(*env)->CallStaticObjectMethod(
env, overlay->java_class, overlay->take_pixels_method);
// CallStaticObjectMethod also doesn't surface Java exceptions on its
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
// silently look identical to "nothing changed yet" (a null return).
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: overlay nativeTakePixelsIfDirty() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return;
}
if (pixels == NULL)
return;
(*env)->GetByteArrayRegion(env, pixels, 0, overlay->width * overlay->height * 4,
(jbyte *)overlay->pixel_cache);
(*env)->DeleteLocalRef(env, pixels);
overlay->pixel_generation++;
}
// Uploads pixel_cache into source_texture via plain (gl4es-routed)
// glBindTexture/glTexSubImage2D, matching how the texture was created.
// Skipped when nothing changed since the last upload. Texture-unit-0 state
// is saved and restored around the upload, since this call is gl4es-routed
// and the engine's own next-frame rendering assumes nothing touched its
// texture bindings since it last drew.
static void xr_overlay_upload_source_texture_if_dirty(XrOverlay *overlay) {
if (overlay->source_uploaded_generation == overlay->pixel_generation)
return;
GLint prevActiveTexture = GL_TEXTURE0;
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
glActiveTexture(GL_TEXTURE0);
GLint prevTexture2D = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, overlay->width, overlay->height, GL_RGBA,
GL_UNSIGNED_BYTE, overlay->pixel_cache);
overlay->source_uploaded_generation = overlay->pixel_generation;
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
if (prevActiveTexture != GL_TEXTURE0)
glActiveTexture((GLenum)prevActiveTexture);
}
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad) {
if (overlay == NULL || !overlay->visible)
return false;
if (!xr_swapchain_acquire(overlay->instance, &overlay->swapchain))
return false;
xr_overlay_refresh_pixel_cache(overlay);
xr_overlay_upload_source_texture_if_dirty(overlay);
// Copies source_texture (via source_fbo) directly into the swapchain
// image xr_swapchain_acquire() just bound - sized to exactly
// width x height, so the destination is always the whole image - using
// the real GLES3 hardware blit (glBlitFramebuffer) rather than a
// shader-based full-screen-quad draw. A blit has no vertex/fragment
// shading stage, no shader program, and no texture units involved at
// all, so gl4es's fixed-pipeline-emulation layer - which
// unconditionally substitutes a customized shader (reproducing the
// game's own last-bound texture/fixed-function state) onto any
// gl4es-routed draw call - has nothing to intercept here. It also
// never touches gl4es's own tracked program/vertex-array/
// texture-binding shadow state, so the only piece of state that needs
// save/restore is the READ framebuffer binding; binding only
// GL_READ_FRAMEBUFFER, rather than the combined GL_FRAMEBUFFER target,
// leaves the DRAW side (the swapchain image itself) untouched
// throughout.
//
// Y is flipped between source and destination: source_texture's texel
// row v=0 holds the Java view's Bitmap row 0 (the top of the rendered
// content, standard top-row-first raster order), and reading that same
// texture via an attached FBO, framebuffer y=0 accesses that identical
// row. The destination swapchain framebuffer follows the opposite
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
// keeps the content right-side up (glBlitFramebuffer supports inverted
// src/dst rects for exactly this).
GLint prevReadFbo = 0;
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, overlay->source_fbo);
real_glBlitFramebuffer(0, 0, overlay->width, overlay->height, 0, overlay->height,
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));
out_quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
out_quad->subImage.swapchain = overlay->swapchain.swapchain;
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
// Rotation about the vertical axis only (see the struct field's own
// comment) - same R_y(-yaw_deg) quaternion construction as
// xr_session.c's own play-space rebuild, for the same reason: this
// quad's pose is expressed as "yaw_deg turned from its own local -Z",
// so a point/ray in that rotated frame maps back into the (unrotated)
// base space by rotating by -yaw_deg.
float half = -(overlay->yaw_deg * QS_DEG_TO_RAD) * 0.5f;
out_quad->pose.orientation.x = 0.0f;
out_quad->pose.orientation.y = sinf(half);
out_quad->pose.orientation.z = 0.0f;
out_quad->pose.orientation.w = cosf(half);
out_quad->pose.position.x = overlay->center_x_m;
out_quad->pose.position.y = overlay->center_y_m;
out_quad->pose.position.z = -overlay->distance_m;
out_quad->size.width = overlay->half_width_m * 2.0f;
out_quad->size.height = overlay->half_height_m * 2.0f;
return true;
}
+117
View File
@@ -0,0 +1,117 @@
// Generic "off-screen Android View rendered into its own OpenXR quad,
// hit-tested/touched via a laser pointer" module - each instance owns its
// own swapchain (via xr_swapchain.h), its own visible/position state, and
// the JNI glue to a backing Java class (menu launcher, keyboard, or any
// future such panel) that must expose these static methods, matching the
// pattern MenuOverlay.java already established:
// static void nativeInit(Activity activity)
// static byte[] nativeTakePixelsIfDirty()
// static void nativeDispatchTouch(float u, float v, boolean down)
// static void nativeUpdateCursor(float u, float v, boolean visible)
//
// xr_input.c drives touch/cursor dispatch and (for panels that support it,
// like the keyboard's title bar) dragging via xr_overlay_set_position();
// xr_session.c owns instance lifetime and calls
// xr_overlay_render_and_build_layer() once per frame per instance.
#ifndef QUESTSHOCK_XR_OVERLAY_H
#define QUESTSHOCK_XR_OVERLAY_H
#include <stdbool.h>
#include <stdint.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct XrOverlay XrOverlay;
typedef struct {
// Fully-qualified Java class name backing this overlay, e.g.
// "de.ladkau.questshock.KeyboardOverlay" - resolved via the activity's
// own ClassLoader (see xr_overlay_find_class() in xr_overlay.c).
const char *java_class_name;
// Pixel size of the overlay's own content/swapchain.
int width;
int height;
// Initial local-space pose (meters, in front of the viewer along -Z) -
// see xr_overlay_get_quad_extent(). width_m is the quad's physical
// width; its height is derived to preserve width/height's aspect.
float distance_m;
float width_m;
float default_center_x_m;
float default_center_y_m;
} XrOverlayConfig;
// instance/session are used to create this overlay's own swapchain
// (format shared with every other quad this app submits - see
// xr_session.c's chosenFormat). Returns NULL (logged) on failure - the
// caller just has no such panel.
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
const XrOverlayConfig *config);
void xr_overlay_destroy(XrOverlay *overlay);
void xr_overlay_toggle_visible(XrOverlay *overlay);
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
bool xr_overlay_is_visible(const XrOverlay *overlay);
// Base-space pose/size of the quad, valid regardless of visibility - shared
// with xr_input.c's ray/quad hit-testing. yaw_deg is rotation about the
// vertical axis (see xr_overlay_set_pose()) - 0 unless something has
// called that.
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg);
// Repositions the quad (base-space X/Y offset, same units/space as
// xr_overlay_get_quad_extent()'s center_x_m/center_y_m) - e.g. driven by
// xr_input.c while the user drags a panel's title bar. Distance from the
// viewer and rotation aren't adjustable this way - only left/right/up/down
// repositioning, not push/pull or turning - see xr_overlay_set_pose() for
// that.
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m);
// Full reposition: X/Y offset, distance ("push/pull"), and rotation about
// the vertical axis (degrees, same sign convention as
// xr_session_set_player_transform()'s yaw_deg) - used by xr_input.c to
// re-center the menu launcher in front of wherever the player currently
// is/facing each time it's freshly reopened, unlike
// xr_overlay_set_position()'s drag-only X/Y-at-fixed-distance-and-facing.
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
float yaw_deg);
// The overlay's original, never-mutated distance_m (from XrOverlayConfig at
// creation time) - unlike xr_overlay_get_quad_extent()'s live distance_m,
// which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate
// once any rotation is involved (see xr_overlay_set_pose()'s own comment),
// this stays a stable "how far in front" magnitude across repeated
// recenters. 0 for a NULL overlay.
float xr_overlay_get_default_distance_m(const XrOverlay *overlay);
// Forwards a hit on the quad (in the quad's own 0..1 u/v, top-left origin)
// to the backing Java view as a synthetic touch down/up.
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
// Updates the on-quad cursor the Java view draws at the current aim hit
// point (same u/v convention as xr_overlay_touch()) - called once per
// frame regardless of click state, so the cursor tracks the ray
// continuously. visible=false hides it.
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible);
// If visible: acquires this overlay's next swapchain image, blits the
// Java view's latest rendered pixels into it, releases the image, and
// fills *out_quad's subImage/pose/size. space/eyeVisibility/layerFlags are
// left for the caller to set (shared/policy choices across every layer
// this app submits, e.g. translucency - not this module's concern).
// Returns false (out_quad untouched) if not visible or something failed -
// the caller should skip submitting a layer for it this frame.
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad);
#ifdef __cplusplus
}
#endif
#endif
+897
View File
@@ -0,0 +1,897 @@
#include "xr_session.h"
#include <ctype.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <SDL.h>
#include "xr_input.h"
#include "xr_overlay.h"
#include "xr_swapchain.h"
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Original, pre-screen-profile-feature default distance for the game
// screen - 2m in front of the local space origin - now just the initial
// value of g_screen_distance_m (below) rather than a fixed constant.
// Width/curve/distance are all configurable (see g_screen_width_m/
// g_screen_curve_deg/g_screen_distance_m above and CONFIG.md's Video
// Options "Initial Screen"/"Game Screen" settings), sized to preserve the
// game's own pixel aspect ratio at whatever physical width is currently
// configured. No per-eye view/projection work is needed for a quad/
// cylinder layer at all - the compositor handles reprojecting it per eye
// itself, which is exactly why this milestone doesn't need xrLocateViews
// or any stereo rendering (see the plan's step A notes).
#define QUAD_DISTANCE_METERS 2.0f
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
// small and sits above where the keyboard defaults to; the keyboard keeps
// its previous size/position. They're independent XrOverlay instances (see
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
// positioned apart by default so they don't start out overlapping.
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
// 768x800 (was 512x256, then 768x512, then 768x552) - grown to fit the
// header/tabs/Video Options content added on top of the original single
// "Keyboard" button, then again to fit the stats ribbon below the tab
// bar, then again to fit the Video Options tab's screen-size grid; taller
// in world space too since MENU_WIDTH_METERS (the physical size) stays
// fixed and height_m is derived from it by aspect ratio (see
// xr_overlay.c's xr_overlay_init()). Must match MenuOverlay.java's own
// WIDTH/HEIGHT, and xr_input.c's MENU_TITLE_BAR_V_FRACTION denominator.
#define MENU_WIDTH 768
#define MENU_HEIGHT 800
#define MENU_DISTANCE_METERS 1.3f
// Physical size - was 0.35m, too small to read comfortably at
// MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon
// pack in; same pixel resolution (MENU_WIDTH/HEIGHT above) just displayed
// bigger, not a layout change.
#define MENU_WIDTH_METERS 0.5f
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f
#define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay"
#define KEYBOARD_WIDTH 1024
#define KEYBOARD_HEIGHT 768
#define KEYBOARD_DISTANCE_METERS 1.5f
#define KEYBOARD_WIDTH_METERS 0.8f
#define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f
#define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f
static XrInstance g_instance = XR_NULL_HANDLE;
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
static XrSession g_session = XR_NULL_HANDLE;
static XrSpace g_local_space = XR_NULL_HANDLE;
// Everything this app submits/locates (game/menu/keyboard/beam quads, hand
// and head poses) is expressed relative to g_play_space, not g_local_space
// directly - it's g_local_space translated/rotated by the inverse of the
// player's own virtual transform (see xr_session_set_player_transform()'s
// own comment in xr_session.h and xr_session_rebuild_play_space() below),
// so moving/turning the player moves/turns everything else relative to
// them without touching any actual pose-building or hit-testing math
// elsewhere in the app - it's all still "relative to the base space",
// which just happens to no longer be g_local_space itself.
static XrSpace g_play_space = XR_NULL_HANDLE;
static float g_player_x = 0.0f;
static float g_player_z = 0.0f;
static float g_player_yaw_deg = 0.0f;
// Set by xr_session_set_player_transform() (including before xr_init() has
// even run - see its own comment), cleared once xr_session_rebuild_play_space()
// actually rebuilds g_play_space from the current x/z/yaw_deg - checked
// once per frame in xr_frame_end() so a stick held at rest costs nothing.
static bool g_player_transform_dirty = false;
static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// Distinct from g_session_state - true from a successful xrBeginSession
// until xrEndSession/loss/exit. Many runtimes only advance the *state*
// (e.g. READY -> SYNCHRONIZED -> VISIBLE -> FOCUSED) in response to the
// app actually calling xrWaitFrame/xrBeginFrame/xrEndFrame continuously
// after xrBeginSession - gating frame submission on already having
// reached SYNCHRONIZED+ (as this used to) is a chicken-and-egg deadlock,
// since the runtime never progresses past READY without the frame loop
// running in the first place. Frame calls just need the session to have
// begun, per the standard OpenXR sample pattern - not any particular
// later state.
static bool g_session_running = false;
static XrSwapchainState g_game_swapchain;
static XrOverlay *g_menu_overlay = NULL;
static XrOverlay *g_keyboard_overlay = NULL;
static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false;
static bool g_have_acquired_game_image = false;
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
// below) - only present if the runtime advertised it at xrCreateInstance
// time (checked via xr_instance_extension_supported()), since unlike the
// two always-required KHR extensions above, this one is a device-capability
// extra that a build shouldn't hard-depend on.
static bool g_performance_metrics_supported = false;
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
// Resolved once, at startup, by string-matching the runtime's own
// human-readable counter path names (see xr_session_init_performance_metrics())
// rather than hardcoding the exact path strings the spec defines - a
// substring match is robust to spec/runtime wording we can't verify without
// on-device testing. XR_NULL_PATH (0) if no match was found.
static XrPath g_cpu_utilization_path = XR_NULL_PATH;
static XrPath g_gpu_utilization_path = XR_NULL_PATH;
// XR_KHR_composition_layer_cylinder support - same "only if the runtime
// advertised it" reasoning as g_performance_metrics_supported above. If
// unsupported, the curve is forced to 0 (flat) everywhere regardless of
// configuration (see xr_get_game_quad_extent()) so the game screen just
// silently stays flat instead of failing to submit a layer at all.
static bool g_cylinder_layer_supported = false;
// The game screen's currently-configured shape (see qs_config.c's
// qs_screen_profile_notify_loop_mode(), which is what actually calls
// xr_session_set_game_screen_shape() below - this file has no notion of
// "which profile" is active, only "what shape is current"). Defaults
// match the game quad's own original, pre-this-feature hardcoded width
// (1.6m) and lack of curve, so a build that never touches the new config
// options renders identically to before.
static float g_screen_width_m = 1.6f;
static float g_screen_curve_deg = 0.0f;
static float g_screen_distance_m = QUAD_DISTANCE_METERS;
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
if (g_instance != XR_NULL_HANDLE)
xrResultToString(g_instance, result, resultString);
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
return false;
}
static PFN_xrVoidFunction xr_get_proc(const char *name) {
PFN_xrVoidFunction fn = NULL;
// xrGetInstanceProcAddr itself works with a NULL instance for the
// handful of functions (like xrInitializeLoaderKHR) that must be called
// before an instance exists.
if (!xr_check(xrGetInstanceProcAddr(g_instance, name, &fn), name))
return NULL;
return fn;
}
// Queried before xrCreateInstance (which is why this takes no instance
// handle - xrEnumerateInstanceExtensionProperties is one of the few
// functions that works without one) to decide whether it's safe to add
// `name` to enabledExtensionNames - passing an unsupported extension there
// would fail instance creation outright, unlike a missing optional feature
// discovered afterward.
static bool xr_instance_extension_supported(const char *name) {
uint32_t count = 0;
if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL),
"xrEnumerateInstanceExtensionProperties"))
return false;
XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count);
for (uint32_t i = 0; i < count; i++)
props[i].type = XR_TYPE_EXTENSION_PROPERTIES;
bool supported = false;
if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props),
"xrEnumerateInstanceExtensionProperties")) {
for (uint32_t i = 0; i < count; i++) {
if (strcmp(props[i].extensionName, name) == 0) {
supported = true;
break;
}
}
}
free(props);
return supported;
}
// Case-insensitively checks whether `needle` occurs anywhere in `haystack` -
// used to identify a counter path (e.g. ".../device/cpu_utilization_average")
// by a short, stable substring instead of matching its exact full spelling.
static bool xr_path_name_contains(const char *haystack, const char *needle) {
size_t hlen = strlen(haystack);
char lower[256];
if (hlen >= sizeof(lower))
hlen = sizeof(lower) - 1;
for (size_t i = 0; i < hlen; i++)
lower[i] = (char)tolower((unsigned char)haystack[i]);
lower[hlen] = '\0';
return strstr(lower, needle) != NULL;
}
// Enables XR_META_performance_metrics (if the runtime advertised it - see
// xr_instance_extension_supported() above) and resolves the CPU/GPU
// device-utilization counter paths xr_session_update_frame_stats() polls
// every frame. Called once, right after xrCreateSession succeeds. Leaves
// g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their
// getters then report -1.0f) if anything here doesn't pan out - this is a
// nice-to-have stats display, not something worth failing session bring-up
// over.
static void xr_session_init_performance_metrics(void) {
if (!g_performance_metrics_supported)
return;
PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths =
(PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc(
"xrEnumeratePerformanceMetricsCounterPathsMETA");
g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc(
"xrSetPerformanceMetricsStateMETA");
g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc(
"xrQueryPerformanceMetricsCounterMETA");
if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL ||
g_xrQueryPerformanceMetricsCounterMETA == NULL)
return;
XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META};
state.enabled = XR_TRUE;
if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state),
"xrSetPerformanceMetricsStateMETA"))
return;
uint32_t pathCount = 0;
if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL),
"xrEnumeratePerformanceMetricsCounterPathsMETA"))
return;
XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount);
if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths),
"xrEnumeratePerformanceMetricsCounterPathsMETA")) {
free(paths);
return;
}
for (uint32_t i = 0; i < pathCount; i++) {
char pathName[256];
uint32_t pathNameLen = 0;
if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName),
"xrPathToString"))
continue;
if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization"))
g_cpu_utilization_path = paths[i];
if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization"))
g_gpu_utilization_path = paths[i];
}
free(paths);
LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s",
g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found",
g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found");
}
// The Android OpenXR loader needs the JavaVM/context before anything else -
// separate from XR_KHR_android_create_instance below, which only affects
// xrCreateInstance itself. Skipping this is a common cause of
// xrCreateInstance silently failing to find a runtime on Android.
static bool xr_initialize_android_loader(JavaVM *vm, jobject activity) {
PFN_xrInitializeLoaderKHR initializeLoader =
(PFN_xrInitializeLoaderKHR)xr_get_proc("xrInitializeLoaderKHR");
if (initializeLoader == NULL) {
LOGE("XR: xrInitializeLoaderKHR not available - no Android OpenXR loader present?");
return false;
}
XrLoaderInitInfoAndroidKHR loaderInitInfo = {XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loaderInitInfo.applicationVM = vm;
loaderInitInfo.applicationContext = activity;
return xr_check(initializeLoader((const XrLoaderInitInfoBaseHeaderKHR *)&loaderInitInfo),
"xrInitializeLoaderKHR");
}
// eglGetCurrentDisplay()/eglGetCurrentContext() hand us the display/context,
// but not the EGLConfig they were created with - eglQueryContext's
// EGL_CONFIG_ID plus eglChooseConfig is the standard way to recover it (the
// same trick Khronos' own hello_xr sample uses).
static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext context) {
EGLint configId = 0;
eglQueryContext(display, context, EGL_CONFIG_ID, &configId);
EGLint attribs[] = {EGL_CONFIG_ID, configId, EGL_NONE};
EGLConfig config = NULL;
EGLint numConfigs = 0;
eglChooseConfig(display, attribs, &config, 1, &numConfigs);
return config;
}
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
// Destroys/recreates g_play_space from g_local_space, offset by the
// inverse of the player's current virtual transform (g_player_x/z/yaw_deg) -
// see xr_session_set_player_transform()'s comment in xr_session.h for why
// this is the inverse rather than the transform itself. Given
// V = Translate(x, 0, z) * Rotate_Y(yaw_deg) (the player's own transform,
// "apparent pose in play space = V composed with real pose in local
// space"), the new space's poseInReferenceSpace must be V's inverse:
// orientation = Rotate_Y(-yaw_deg), position = Rotate_Y(-yaw_deg) * (-x, 0, -z).
// Called once, right after g_local_space itself is created (picking up
// whatever qs_config_load_startup() already seeded before xr_init() ever
// ran), and once per frame from xr_frame_end() whenever
// g_player_transform_dirty says the transform actually changed since the
// last rebuild - a plain stick-at-rest frame costs nothing beyond that one
// bool check. g_session must already exist.
static void xr_session_rebuild_play_space(void) {
if (g_play_space != XR_NULL_HANDLE) {
xrDestroySpace(g_play_space);
g_play_space = XR_NULL_HANDLE;
}
float theta = g_player_yaw_deg * QS_DEG_TO_RAD;
float half = -theta * 0.5f;
float c = cosf(-theta), s = sinf(-theta);
float vx = -g_player_x, vz = -g_player_z;
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
spaceInfo.poseInReferenceSpace.orientation.x = 0.0f;
spaceInfo.poseInReferenceSpace.orientation.y = sinf(half);
spaceInfo.poseInReferenceSpace.orientation.z = 0.0f;
spaceInfo.poseInReferenceSpace.orientation.w = cosf(half);
spaceInfo.poseInReferenceSpace.position.x = vx * c + vz * s;
spaceInfo.poseInReferenceSpace.position.y = 0.0f;
spaceInfo.poseInReferenceSpace.position.z = -vx * s + vz * c;
xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_play_space),
"xrCreateReferenceSpace(play)");
g_player_transform_dirty = false;
}
void xr_session_set_player_transform(float x, float z, float yaw_deg) {
g_player_x = x;
g_player_z = z;
g_player_yaw_deg = yaw_deg;
g_player_transform_dirty = true;
}
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg) {
*x = g_player_x;
*z = g_player_z;
*yaw_deg = g_player_yaw_deg;
}
XrSpace xr_session_get_local_space(void) { return g_local_space; }
XrTime xr_session_get_predicted_display_time(void) { return g_predicted_display_time; }
static bool xr_create_instance_and_session(int game_width, int game_height) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: no JNIEnv/Activity from SDL - can't init OpenXR");
return false;
}
JavaVM *vm = NULL;
(*env)->GetJavaVM(env, &vm);
if (!xr_initialize_android_loader(vm, activity))
return false;
g_performance_metrics_supported =
xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
g_cylinder_layer_supported =
xr_instance_extension_supported(XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME);
// The two KHR extensions here are always required; the other two are
// each independently optional device capabilities (see
// g_performance_metrics_supported/g_cylinder_layer_supported's own
// comments) - appended only if actually advertised, since listing an
// unsupported extension fails xrCreateInstance entirely, not just that
// one feature.
const char *extensions[4] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
};
uint32_t extensionCount = 2;
if (g_performance_metrics_supported)
extensions[extensionCount++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME;
if (g_cylinder_layer_supported)
extensions[extensionCount++] = XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME;
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = vm;
androidInfo.applicationActivity = activity;
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
createInfo.next = &androidInfo;
createInfo.enabledExtensionCount = extensionCount;
createInfo.enabledExtensionNames = extensions;
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
XR_MAX_APPLICATION_NAME_SIZE - 1);
strncpy(createInfo.applicationInfo.engineName, "Shockolate", XR_MAX_ENGINE_NAME_SIZE - 1);
createInfo.applicationInfo.applicationVersion = 1;
createInfo.applicationInfo.engineVersion = 1;
createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION;
if (!xr_check(xrCreateInstance(&createInfo, &g_instance), "xrCreateInstance"))
return false;
XrSystemGetInfo systemInfo = {XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
if (!xr_check(xrGetSystem(g_instance, &systemInfo, &g_system_id), "xrGetSystem"))
return false;
// Required by spec before creating an OpenGL ES-backed session, even
// though we don't gate on the returned min/max API version here.
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc(
"xrGetOpenGLESGraphicsRequirementsKHR");
if (getGLESRequirements == NULL)
return false;
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {
XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
"xrGetOpenGLESGraphicsRequirementsKHR"))
return false;
EGLDisplay display = eglGetCurrentDisplay();
EGLContext context = eglGetCurrentContext();
if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT) {
LOGE("XR: no current EGL display/context - call xr_init() after init_opengl()");
return false;
}
XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding = {
XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
graphicsBinding.display = display;
graphicsBinding.config = xr_get_current_egl_config(display, context);
graphicsBinding.context = context;
XrSessionCreateInfo sessionInfo = {XR_TYPE_SESSION_CREATE_INFO};
sessionInfo.next = &graphicsBinding;
sessionInfo.systemId = g_system_id;
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
return false;
xr_session_init_performance_metrics();
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
if (!xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_local_space),
"xrCreateReferenceSpace"))
return false;
// Picks up whatever qs_config_load_startup() already stored into
// g_player_x/z/yaw_deg via xr_session_set_player_transform() before
// this function ever ran (that call is pure state, no XR API calls, so
// it works fine even this early) - so a saved position is already in
// effect from the very first frame, not just after the player first
// moves a thumbstick.
xr_session_rebuild_play_space();
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
// data conventionally is) and get uploaded/sampled as plain linear
// GL_RGBA with no decode step anywhere in this path, matching the
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
// would auto-gamma-encode on write and double-encode already-encoded
// data. Every swapchain below (game quad, menu/keyboard overlays, and
// - via xr_input_init() - the per-hand laser-beam overlays) shares
// this one choice - the compositor's supported format set doesn't
// depend on swapchain size.
uint32_t formatCount = 0;
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
xrEnumerateSwapchainFormats(g_session, formatCount, &formatCount, formats);
int64_t chosenFormat = formats[0];
for (uint32_t i = 0; i < formatCount; i++) {
LOGI("XR: swapchain format[%u] = 0x%llx", i, (unsigned long long)formats[i]);
if (formats[i] == GL_RGBA8) {
chosenFormat = GL_RGBA8;
break;
}
}
free(formats);
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
// keeps working either way, just without the laser pointer/beams.
xr_input_init(g_instance, g_session, chosenFormat);
if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
&g_game_swapchain)) {
LOGE("XR: game swapchain setup failed");
return false;
}
// Both non-fatal - the game keeps rendering with no such panel if
// either fails (e.g. the Java class couldn't be resolved via JNI).
XrOverlayConfig menuConfig = {
.java_class_name = MENU_JAVA_CLASS,
.width = MENU_WIDTH,
.height = MENU_HEIGHT,
.distance_m = MENU_DISTANCE_METERS,
.width_m = MENU_WIDTH_METERS,
.default_center_x_m = MENU_DEFAULT_CENTER_X_METERS,
.default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS,
};
g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig);
XrOverlayConfig keyboardConfig = {
.java_class_name = KEYBOARD_JAVA_CLASS,
.width = KEYBOARD_WIDTH,
.height = KEYBOARD_HEIGHT,
.distance_m = KEYBOARD_DISTANCE_METERS,
.width_m = KEYBOARD_WIDTH_METERS,
.default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS,
.default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS,
};
g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig);
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width,
game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
return true;
}
bool xr_init(int game_width, int game_height) {
if (!xr_create_instance_and_session(game_width, game_height)) {
LOGE("XR: bring-up failed - falling back to the normal window present path");
xr_shutdown();
return false;
}
return true;
}
XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; }
XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; }
void xr_poll_events(void) {
if (g_instance == XR_NULL_HANDLE)
return;
while (true) {
XrEventDataBuffer event = {XR_TYPE_EVENT_DATA_BUFFER};
XrResult result = xrPollEvent(g_instance, &event);
if (result == XR_EVENT_UNAVAILABLE)
break;
if (!xr_check(result, "xrPollEvent"))
break;
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
XrEventDataSessionStateChanged *stateEvent = (XrEventDataSessionStateChanged *)&event;
g_session_state = stateEvent->state;
LOGI("XR: session state -> %d", (int)g_session_state);
if (g_session_state == XR_SESSION_STATE_READY) {
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
g_session_running =
xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
} else if (g_session_state == XR_SESSION_STATE_STOPPING) {
xr_check(xrEndSession(g_session), "xrEndSession");
g_session_running = false;
} else if (g_session_state == XR_SESSION_STATE_EXITING ||
g_session_state == XR_SESSION_STATE_LOSS_PENDING) {
g_session_running = false;
}
}
}
// xrPollEvent() leaves gl4es reporting a stray GL_INVALID_OPERATION on
// every call while the session is running - drain it (looped, since
// more than one can accumulate across xrPollEvent()'s possibly-multiple
// iterations above) so it doesn't surface as a false positive at the
// next unrelated glGetError() check in this app's own rendering code.
while (glGetError() != GL_NO_ERROR) {
}
}
bool xr_is_session_running(void) { return g_session_running; }
bool xr_frame_begin(void) {
g_have_acquired_game_image = false;
if (!xr_is_session_running())
return false;
XrFrameWaitInfo waitInfo = {XR_TYPE_FRAME_WAIT_INFO};
XrFrameState frameState = {XR_TYPE_FRAME_STATE};
if (!xr_check(xrWaitFrame(g_session, &waitInfo, &frameState), "xrWaitFrame"))
return false;
g_predicted_display_time = frameState.predictedDisplayTime;
g_frame_should_render = frameState.shouldRender;
XrFrameBeginInfo beginInfo = {XR_TYPE_FRAME_BEGIN_INFO};
if (!xr_check(xrBeginFrame(g_session, &beginInfo), "xrBeginFrame"))
return false;
if (!g_frame_should_render)
return false;
g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain);
return g_have_acquired_game_image;
}
// Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential
// smoothing (rather than a plain per-frame instantaneous value) so
// MenuOverlay's header display doesn't visibly jitter every single frame.
// Called once per xr_frame_end() that reaches its own end (i.e. once per
// real XR frame, whether or not the game itself actually rendered anything
// new into it this frame - matches the OpenXR compositor's own frame
// cadence, which is what "FPS" means to someone looking at this display).
static float s_fps = 0.0f;
static float s_frame_ms = 0.0f;
static float s_cpu_utilization = -1.0f;
static float s_gpu_utilization = -1.0f;
// Reads one XR_META_performance_metrics counter (if it was found at
// startup - see xr_session_init_performance_metrics()) and folds it into
// *smoothed with the same exponential smoothing as FPS/frame time above.
// Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the
// path wasn't found, the query fails, or the runtime didn't mark its
// floatValue valid this call.
static void xr_session_update_utilization(XrPath path, float *smoothed) {
if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL)
return;
XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META};
if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter),
"xrQueryPerformanceMetricsCounterMETA"))
return;
if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META))
return;
const float kSmoothing = 0.1f;
*smoothed =
(*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing;
}
static void xr_session_update_frame_stats(void) {
static Uint32 s_last_ticks = 0;
Uint32 now = SDL_GetTicks();
if (s_last_ticks != 0) {
Uint32 delta_ms = now - s_last_ticks;
if (delta_ms > 0) {
float instant_fps = 1000.0f / (float)delta_ms;
const float kSmoothing = 0.1f;
s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing;
s_frame_ms =
(s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing;
}
}
s_last_ticks = now;
xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization);
xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization);
}
float xr_session_get_fps(void) { return s_fps; }
float xr_session_get_frame_ms(void) { return s_frame_ms; }
float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; }
float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; }
void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
// Rebuilt before xr_input_sync_and_draw() below so this same frame's
// hit-testing and layer submission both see whatever the player's
// thumbstick input (or a config-driven "Reset Position"/Load
// Configuration) changed last frame - a no-op check on every frame
// nothing moved.
if (g_player_transform_dirty)
xr_session_rebuild_play_space();
// Syncs actions and hit-tests/dispatches each hand's ray against
// keyboard/menu/game quad (see xr_input.c) before the game swapchain
// image is released below - this always runs, even when there's
// nothing to draw (no acquired image this frame), so edge detection
// (trigger/menu-button clicks) doesn't miss a frame. Any resulting
// laser-beam quads are submitted as their own composition layers
// further down, not drawn into the game swapchain itself.
if (xr_is_session_running())
xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
g_have_acquired_game_image);
if (g_have_acquired_game_image)
xr_swapchain_release(g_instance, &g_game_swapchain);
if (!xr_is_session_running())
return;
// Up to 5 layers: the game screen (if a frame was actually rendered) -
// a flat quad, or a curved cylinder if the currently-active screen
// profile has a nonzero curve (see xr_get_game_quad_extent()'s own
// comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever
// shape is actually submitted here, always agreeing since both read
// the same getter) - the menu launcher and keyboard overlays while
// visible - each gets its own acquire/render/release cycle against
// its own swapchain (see xr_overlay_render_and_build_layer()) any
// time before xrEndFrame, unlike the game screen there's no per-frame
// engine rendering to wrap around here, just each overlay's own blit -
// and, per hand, a laser-beam quad while that hand's ray is aimed at
// the keyboard, menu, or the game screen itself (see
// xr_input_get_beam_layer()).
float gameDistance, gameHalfWidth, gameHalfHeight, gameRadius, gameCentralAngle;
xr_get_game_quad_extent(&gameDistance, &gameHalfWidth, &gameHalfHeight, &gameRadius,
&gameCentralAngle);
bool gameScreenCurved = gameCentralAngle > 0.0f;
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
XrCompositionLayerCylinderKHR gameCylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR};
if (gameScreenCurved) {
gameCylinder.space = g_play_space;
gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameCylinder.subImage.swapchain = g_game_swapchain.swapchain;
gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width;
gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height;
gameCylinder.pose.orientation.w = 1.0f;
gameCylinder.pose.position.z = -gameDistance;
gameCylinder.radius = gameRadius;
gameCylinder.centralAngle = gameCentralAngle;
gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height;
} else {
gameQuad.space = g_play_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
gameQuad.pose.orientation.w = 1.0f;
gameQuad.pose.position.z = -gameDistance;
gameQuad.size.width = gameHalfWidth * 2.0f;
gameQuad.size.height = gameHalfHeight * 2.0f;
}
const XrCompositionLayerBaseHeader *layers[5];
uint32_t layerCount = 0;
if (g_have_acquired_game_image)
layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder
: (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_play_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
}
XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_play_space;
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
// Lets the keyboard sit on screen translucently while playing
// rather than fully hiding whatever's behind it - KeyboardOverlay
// writes real sub-255 alpha into the swapchain texture (see
// OverlayPanel.compositeAndPublish()), already in Android's
// default premultiplied format, which is what this flag's absence
// of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above
// deliberately doesn't get this flag - it stays fully opaque.
keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad;
}
// Submitted last (frontmost) so a beam doesn't z-fight against the
// panel surface it's aimed at/terminates on - see
// xr_input_get_beam_layer(). Translucent for the same reason the
// keyboard is (see comment above).
XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_play_space;
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
}
}
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
endInfo.layerCount = layerCount;
endInfo.layers = layerCount > 0 ? layers : NULL;
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
xr_session_update_frame_stats();
}
void xr_shutdown(void) {
xr_swapchain_destroy(&g_game_swapchain);
if (g_local_space != XR_NULL_HANDLE)
xrDestroySpace(g_local_space);
g_local_space = XR_NULL_HANDLE;
if (g_play_space != XR_NULL_HANDLE)
xrDestroySpace(g_play_space);
g_play_space = XR_NULL_HANDLE;
// Before the session/instance they were created from.
xr_input_shutdown();
xr_overlay_destroy(g_menu_overlay);
g_menu_overlay = NULL;
xr_overlay_destroy(g_keyboard_overlay);
g_keyboard_overlay = NULL;
if (g_session != XR_NULL_HANDLE)
xrDestroySession(g_session);
g_session = XR_NULL_HANDLE;
if (g_instance != XR_NULL_HANDLE)
xrDestroyInstance(g_instance);
g_instance = XR_NULL_HANDLE;
g_session_state = XR_SESSION_STATE_UNKNOWN;
g_session_running = false;
g_performance_metrics_supported = false;
g_xrSetPerformanceMetricsStateMETA = NULL;
g_xrQueryPerformanceMetricsCounterMETA = NULL;
g_cpu_utilization_path = XR_NULL_PATH;
g_gpu_utilization_path = XR_NULL_PATH;
g_cylinder_layer_supported = false;
// g_screen_width_m/g_screen_curve_deg/g_screen_distance_m deliberately
// NOT reset here - same reasoning as xr_session_set_player_transform()'s
// own state: it's meant to persist across a shutdown/reinit within the
// same process (e.g. an Android lifecycle pause/resume), not just
// within one XR session.
}
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m) {
g_screen_width_m = width_m;
g_screen_curve_deg = curve_deg;
g_screen_distance_m = distance_m;
}
// Single source of truth for the game screen's current shape, read by
// both xr_frame_end() (to build/submit the actual layer) and
// xr_input_try_game_quad() (xr_input.c, to hit-test the laser pointer
// against it). half_width_m is meaningful only when *central_angle_rad is
// 0 (flat); a curved screen's horizontal extent is bounded by
// *central_angle_rad instead, with *half_height_m always the vertical
// bound either way.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
float *radius_m, float *central_angle_rad) {
float curveDeg = g_cylinder_layer_supported ? g_screen_curve_deg : 0.0f;
float aspect = (float)g_game_swapchain.height / (float)g_game_swapchain.width;
*distance_m = g_screen_distance_m;
*half_width_m = g_screen_width_m * 0.5f;
*half_height_m = g_screen_width_m * 0.5f * aspect;
if (curveDeg > 0.0f) {
float theta = curveDeg * QS_DEG_TO_RAD;
*central_angle_rad = theta;
*radius_m = g_screen_width_m / theta;
} else {
*central_angle_rad = 0.0f;
*radius_m = 0.0f;
}
}
void xr_get_game_swapchain_size(int *width, int *height) {
*width = g_game_swapchain.width;
*height = g_game_swapchain.height;
}
void xr_get_game_resolution(int *width, int *height) {
// Not g_game_swapchain.width/height: that's the fixed physical size of
// the swapchain image the game is composited into (see xr_init()) -
// android_draw_surface_as_quad() (OpenGL.cc, engine-patches/11) always
// stretch-blits drawSurface to fill that image regardless of
// drawSurface's own pixel size, so the swapchain's size has no relation
// to the coordinate space MouseX/MouseY actually live in. gScreenWide/
// gScreenHigh (InitMac.h) is drawSurface's current size - the
// resolution the in-game options menu can change at runtime
// (ShockBitmap.c's ChangeScreenSize()) independently of the swapchain,
// which is created once at launch and never resized. Using the fixed
// swapchain size here after such a change mapped the laser pointer's
// quad-space hit into the wrong coordinate space entirely, moving
// MouseX/MouseY out of the engine's actual screen bounds - which is
// what made the cursor disappear and become unclickable.
extern int32_t gScreenWide, gScreenHigh;
*width = gScreenWide;
*height = gScreenHigh;
}
+173
View File
@@ -0,0 +1,173 @@
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
// game's own rendering untouched (see android/engine-patches/
// 11-android-openxr-present.patch) - this module only owns the OpenXR
// instance/session/swapchains and the final "submit quad layers instead of
// presenting to a window" step. No stereo rendering - the game composite is
// shown as a single flat quad floating in front of the viewer.
#ifndef QUESTSHOCK_XR_SESSION_H
#define QUESTSHOCK_XR_SESSION_H
#include <stdbool.h>
#include "xr_overlay.h"
#ifdef __cplusplus
extern "C" {
#endif
// Call once, right after init_opengl() (OpenGL.cc) has created and made
// current the GL context SDL/gl4es already use - creates the OpenXR
// instance/session sharing that same EGL display/context, plus the game
// quad's own swapchain, sized to the game's logical resolution
// (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). Also
// creates the menu launcher and keyboard overlay quads (see xr_overlay.h) -
// two independent XrOverlay instances, each with their own swapchain,
// visibility, and position, retrievable via xr_session_get_menu_overlay()/
// xr_session_get_keyboard_overlay() below. Returns false if OpenXR
// bring-up failed (e.g. no runtime installed) - callers should fall back
// to the existing window-present path in that case.
bool xr_init(int game_width, int game_height);
// The menu launcher (small "Keyboard" button panel, toggled by the
// controller's menu button) and keyboard (the on-screen key grid, shown/
// hidden independently via MenuOverlay's "Keyboard" button and
// KeyboardOverlay's own Close button) overlay quads - used by xr_input.c
// for ray/quad hit-testing and touch/cursor dispatch, and by
// questshock_native.c's JNI glue (nativeShowKeyboard()/
// nativeRequestClose()). NULL if xr_init() failed before creating them.
XrOverlay *xr_session_get_menu_overlay(void);
XrOverlay *xr_session_get_keyboard_overlay(void);
// Pumps XR session-state events. Call once per frame, before
// xr_frame_begin(). Must still be called even when xr_init() returned
// false (no-op in that case).
void xr_poll_events(void);
// True once the session has reached a state where frames are expected
// (XR_SESSION_STATE_SYNCHRONIZED or later) - i.e. it's safe/required to
// start calling xr_frame_begin()/xr_frame_end() each frame.
bool xr_is_session_running(void);
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
// this frame rendered, acquires the game quad's next swapchain image and
// binds its framebuffer as the current render target - ready for the
// caller to draw into exactly as it would have drawn to the default
// framebuffer. Returns true if the caller should draw this frame;
// xr_frame_end() must be called unconditionally afterward either way (a
// begun XR frame must always be ended, rendered or not).
bool xr_frame_begin(void);
// Releases the game quad's swapchain image (if one was acquired this
// frame), then does the same acquire/render/release cycle for the menu
// launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
// for whichever of them is currently visible, and submits whichever of the
// three quads actually rendered this frame as composition layers
// positioned in front of the play space's origin (see
// xr_session_set_player_transform() above), then ends the XR frame.
void xr_frame_end(void);
void xr_shutdown(void);
// Play-space geometry of the single game screen xr_frame_end() submits
// (distance in front of the play space origin, half-width/half-height,
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
// laser pointer always matches whatever's actually visible. *radius_m/
// *central_angle_rad are 0 when flat, in which case *half_width_m is the
// horizontal extent; otherwise the screen is a curved cylinder segment
// and the caller uses *central_angle_rad instead of *half_width_m for the
// horizontal bound (*half_height_m stays meaningful either way). Curving
// is currently unused - qs_config.c has no config surface for it - but
// kept working so it can be re-enabled later.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
float *radius_m, float *central_angle_rad);
// Sets the game screen's current physical width (meters) and curve (wrap
// angle in degrees, 0 = flat) - a plain state setter, no XR calls, same
// category as xr_session_set_player_transform() above (safe to call
// before xr_init() has even run). Read by both xr_frame_end() (to build/
// submit the actual layer) and, via xr_get_game_quad_extent() above,
// xr_input.c's hit-testing. Driven by qs_config.c's
// qs_screen_profile_notify_loop_mode(), which decides which of the
// "Initial Screen"/"Game Screen" config profiles (see CONFIG.md) is
// currently active - this function itself has no notion of "profiles",
// only "what shape is current".
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m);
// The engine's current screen resolution (gScreenWide/gScreenHigh,
// InitMac.h) - not the game quad's own swapchain size, which is fixed at
// launch and never changes - the logical coordinate space xr_input.c needs
// to convert a game-quad ray hit's normalized u,v into engine mouse
// coordinates (see xr_mouse.h). Can change at runtime via the in-game
// options menu (ShockBitmap.c's ChangeScreenSize()), independently of the
// swapchain.
void xr_get_game_resolution(int *width, int *height);
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
// xr_init()). The quad's aspect ratio is this size, so full-quad content
// drawn outside the engine's own frame path (the startup splash) is laid out
// against it.
void xr_get_game_swapchain_size(int *width, int *height);
// Exponentially-smoothed frame rate/frame time, updated once per real
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
// the second frame has run (nothing to compute a delta from yet).
float xr_session_get_fps(void);
float xr_session_get_frame_ms(void);
// Device-wide CPU/GPU utilization percentage (0-100), via the
// XR_META_performance_metrics extension - same per-frame update cadence as
// the FPS/frame-time getters above. Returns -1.0f if the extension isn't
// supported by the runtime, or its cpu_utilization/gpu_utilization counter
// couldn't be found among the ones it advertises.
float xr_session_get_cpu_utilization(void);
float xr_session_get_gpu_utilization(void);
// The player's virtual position/orientation - horizontal-plane only (x/z
// plus a yaw rotation about the vertical axis); height always comes from
// the real tracked HMD pose, never from this. Everything the app submits
// (game/menu/keyboard/beam quads) and every hand/head pose it locates is
// expressed relative to a second reference space, derived from the real
// LOCAL space by this transform's inverse (see xr_session.c's own
// xr_session_rebuild_play_space()) - so changing it moves/turns the player
// relative to those otherwise-fixed quads, letting them walk up to and
// square up with the game quad ("virtual screen") without needing any
// physical room-scale space to do it in.
//
// xr_session_set_player_transform() only stores the three floats (no XR
// calls) - safe to call before xr_init() has even run, which is how
// qs_config_load_startup() seeds the saved position before the OpenXR
// session/spaces exist yet (xr_init() picks it up when it creates its own
// spaces); also used live by xr_input.c's thumbstick locomotion and
// "Reset Position" recenter (xr_input_recenter_player_to_screen()) and
// qs_config.c's "Load Configuration", and read back by "Save Configuration"
// via the getter.
void xr_session_set_player_transform(float x, float z, float yaw_deg);
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg);
// The real, physical tracking-origin reference space - used only by
// xr_input_recenter_player_to_screen() (xr_input.c), which needs the
// player's CURRENT real head pose (not one already bent through the
// existing play-space transform - see xr_session_set_player_transform()'s
// own comment above) to compute a brand new one. Every other consumer in
// this app (rendering, hit-testing) uses the play space instead - see
// xr_session_rebuild_play_space()'s own comment for why this one stays a
// separate, never-swapped-out space.
XrSpace xr_session_get_local_space(void);
// The most recent xrWaitFrame-predicted display time (xr_frame_begin()'s
// own g_predicted_display_time) - for the same reason as
// xr_session_get_local_space() above, xr_input_recenter_player_to_screen()
// needs a valid XrTime to locate a space with but isn't itself called from
// the normal per-frame xr_frame_end()/xr_input_sync_and_draw() call chain
// that already threads one through (it's triggered by a button press,
// dispatched via qs_config.c's cross-thread event queue instead). A frame
// or so of staleness doesn't matter for a one-shot recenter. 0 before the
// first frame has ever begun.
XrTime xr_session_get_predicted_display_time(void);
#ifdef __cplusplus
}
#endif
#endif
+160
View File
@@ -0,0 +1,160 @@
#include "xr_swapchain.h"
#include <dlfcn.h>
#include <android/log.h>
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
static bool xr_check(XrInstance instance, XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
if (instance != XR_NULL_HANDLE)
xrResultToString(instance, result, resultString);
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
return false;
}
// This file's other GL calls otherwise resolve to gl4es (the only GL
// symbol provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), which is fine for anything shared with the
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
// hands us are real driver texture objects gl4es never created itself,
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
// tracked metadata for. So the FBO *container* is created via gl4es's own
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
// own and its own per-frame glBindFramebuffer succeeds), while the
// texture-attach step - the specifically foreign part - goes through the
// real driver directly, via dlsym against libGLESv2.so.
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
static bool g_real_gles_loaded = false;
static bool xr_swapchain_load_real_gles(void) {
if (g_real_gles_loaded)
return true;
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: swapchain dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glCheckFramebufferStatus =
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
g_real_gles_loaded = real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
return g_real_gles_loaded;
}
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
int height, XrSwapchainState *out) {
if (!xr_swapchain_load_real_gles()) {
LOGE("XR: swapchain couldn't resolve real GLES FBO functions via dlsym");
return false;
}
out->width = width;
out->height = height;
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags =
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
swapchainInfo.format = format;
swapchainInfo.sampleCount = 1;
swapchainInfo.width = (uint32_t)width;
swapchainInfo.height = (uint32_t)height;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(instance, xrCreateSwapchain(session, &swapchainInfo, &out->swapchain),
"xrCreateSwapchain"))
return false;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(out->swapchain, 0, &imageCount, NULL);
if (imageCount > XR_SWAPCHAIN_MAX_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
XR_SWAPCHAIN_MAX_IMAGES);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an uninitialized
// stack array would leave it as garbage.
XrSwapchainImageOpenGLESKHR images[XR_SWAPCHAIN_MAX_IMAGES] = {0};
for (uint32_t i = 0; i < imageCount; i++)
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (!xr_check(instance,
xrEnumerateSwapchainImages(out->swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
out->image_count = imageCount;
// Wraps each swapchain-provided texture in its own framebuffer, matching
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
// without a depth/stencil attachment, since the final composite draw
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
// gl4es's own (linked, not dlsym'd) - see the comment above
// real_glFramebufferTexture2D for why.
bool all_complete = true;
for (uint32_t i = 0; i < imageCount; i++) {
glGenFramebuffers(1, &out->fbos[i]);
glBindFramebuffer(GL_FRAMEBUFFER, out->fbos[i]);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
images[i].image, 0);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return all_complete;
}
void xr_swapchain_destroy(XrSwapchainState *state) {
for (uint32_t i = 0; i < state->image_count; i++) {
if (state->fbos[i] != 0)
glDeleteFramebuffers(1, &state->fbos[i]);
}
state->image_count = 0;
if (state->swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(state->swapchain);
state->swapchain = XR_NULL_HANDLE;
}
// Acquires the next image of the given swapchain, waits for the runtime to
// finish with it, and binds its framebuffer (sized to exactly fill it) as
// the current render target.
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
uint32_t imageIndex = 0;
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if (!xr_check(instance, xrAcquireSwapchainImage(state->swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(instance, xrWaitSwapchainImage(state->swapchain, &waitImageInfo),
"xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
// itself created (see xr_swapchain_create()), so its own "current FBO"
// bookkeeping updates correctly and its immediate-mode draw calls
// (android_draw_surface_as_quad()) land in the right place.
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
glViewport(0, 0, state->width, state->height);
return true;
}
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state) {
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(instance, xrReleaseSwapchainImage(state->swapchain, &releaseInfo),
"xrReleaseSwapchainImage");
}
+55
View File
@@ -0,0 +1,55 @@
// Generic OpenXR swapchain + per-image GL framebuffer helper, shared by
// every quad this app submits - the game quad (xr_session.c) and every
// xr_overlay.c instance (menu launcher, keyboard) - since they all need
// exactly the same setup, just at their own size/format.
#ifndef QUESTSHOCK_XR_SWAPCHAIN_H
#define QUESTSHOCK_XR_SWAPCHAIN_H
#include <stdbool.h>
#include <stdint.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <jni.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#ifdef __cplusplus
extern "C" {
#endif
// Up to this many swapchain images/FBOs - real runtimes report small counts
// (2-4); this is just a fixed upper bound for the cache arrays below.
#define XR_SWAPCHAIN_MAX_IMAGES 8
typedef struct {
XrSwapchain swapchain;
GLuint fbos[XR_SWAPCHAIN_MAX_IMAGES];
uint32_t image_count;
int width;
int height;
} XrSwapchainState;
// Creates the swapchain and wraps each of its images in its own GL
// framebuffer, ready to bind and render into directly. instance is only
// used to log a human-readable error string on failure.
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
int height, XrSwapchainState *out);
void xr_swapchain_destroy(XrSwapchainState *state);
// Acquires the next image, waits for the runtime to finish with it, and
// binds its framebuffer (sized to exactly fill it) as the current render
// target.
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state);
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,586 @@
package de.ladkau.questshock;
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;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
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. 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; 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
* toggle (uppercase/symbols vs lowercase/numbers), never itself sent to the
* game.
*
* This is a fully independent overlay quad from MenuOverlay's small
* launcher - opened via that launcher's "Keyboard" button
* (nativeShowKeyboard()) but otherwise self-contained: once open, the
* controller's menu button (which only ever affects the menu launcher, see
* xr_input.c) doesn't hide it, so it stays up as a standing input panel
* while actually playing rather than a modal you open and close. The only
* way to hide it is its own title bar's Close button (buildTitleBar()),
* which also doubles as a drag handle for repositioning it (handled
* entirely on the native side - see xr_input.c - since it never reaches
* this class's own touch dispatch). contentAlpha() renders the whole panel
* semi-transparent so whatever's behind it stays visible while it's up.
*/
public class KeyboardOverlay extends OverlayPanel {
// Matches xr_session.c's KEYBOARD_WIDTH/KEYBOARD_HEIGHT swapchain size -
// fixed, not tied to any real display metric, since this tree is never
// shown in a real window.
private static final int WIDTH = 1024;
private static final int HEIGHT = 768;
private static final int TYPED_TEXT_HEIGHT = 100;
// Height of the title bar (buildTitleBar()) and width of its Close
// button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// KEYBOARD_CLOSE_BUTTON_U_FRACTION must be kept in sync with these (as
// fractions of WIDTH/HEIGHT above), since the drag-handle-vs-Close-
// button-vs-rest-of-the-keyboard decision happens entirely on the
// native side, before a hit ever reaches this class's own touch
// dispatch.
private static final int TITLE_BAR_HEIGHT = 90;
private static final int CLOSE_BUTTON_WIDTH = 160;
// ~70% opaque - readable but lets whatever's behind the panel (the game
// quad, or empty space) show through while it's left up during play.
private static final int PANEL_ALPHA = 180;
private static KeyboardOverlay instance;
private TextView typedTextView;
private final StringBuilder typedText = new StringBuilder();
// Title bar chrome text - the only pieces of this panel that get
// translated (see Strings.java's own comment on why the actual key
// labels don't). Set from MenuOverlay.nativeGetLanguage() at the top of
// buildContent() (see OverlayPanel's constructor note on why a field a
// subclass's buildContent() depends on can't rely on normal field-
// initializer timing) and kept in sync afterward by applyTranslations()
// below, called from MenuOverlay's own periodic language-refresh check.
private int currentLanguage = Strings.ENGLISH;
private TextView titleLabel;
private Button closeButton;
// Shift is a local-only layer toggle (never itself sent to the game -
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
// sent to the game the moment they're pressed and released again as
// soon as the next key consumes them (see toggleModifier()/
// releaseArmedModifiers()) - there's no press-and-hold gesture with a
// laser pointer + trigger click, so armed-then-consumed stands in for
// holding the key down.
private boolean shiftActive = false;
private boolean ctrlArmed = false;
private boolean altArmed = false;
private Button shiftButton;
private Button ctrlButton;
private Button altButton;
// Every CharKey created by addRow(), so updateShiftVisual() can relabel
// all of them at once when Shift toggles. Assigned (not a field
// initializer) at the top of buildContent() - see OverlayPanel's
// constructor note on why a field a subclass's buildContent() depends
// on can't rely on normal field-initializer timing.
private List<CharKey> charKeys;
// 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 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;
final int androidKeyCode;
Button button;
CharKey(char base, char shifted, int androidKeyCode) {
this.base = base;
this.shifted = shifted;
this.androidKeyCode = androidKeyCode;
}
}
private static CharKey charKey(char base, char shifted, int androidKeyCode) {
return new CharKey(base, shifted, androidKeyCode);
}
// Space is a CharKey like any other (see addRow()), but a literal
// space character makes for an unreadable, blank-looking button - keep
// showing the word "Space" instead, same as before it became a CharKey.
private static String charKeyLabel(CharKey key, boolean shiftActive) {
if (key.base == ' ') {
return "Space";
}
return String.valueOf(shiftActive ? key.shifted : key.base);
}
private KeyboardOverlay(Activity activity) {
super(activity, WIDTH, HEIGHT);
}
@Override
protected int contentAlpha() {
return PANEL_ALPHA;
}
@Override
protected View buildContent() {
charKeys = new ArrayList<>();
currentLanguage = MenuOverlay.nativeGetLanguage();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setBackgroundColor(0xFF202020);
panel.addView(buildTitleBar(),
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, TITLE_BAR_HEIGHT));
typedTextView = new TextView(activity);
typedTextView.setTextColor(0xFFFFFFFF);
typedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
typedTextView.setBackgroundColor(0xFF303030);
typedTextView.setPadding(20, 20, 20, 20);
panel.addView(typedTextView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
// Rows follow real keyboard geometry where it helps (Tab starting
// the QWERTY row, Enter ending the home row, Shift starting the
// bottom-letter row, a compact arrow cluster bottom-right) so the
// layout reads as a familiar keyboard rather than an arbitrary grid.
addRow(panel, "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10",
"F11", "F12", "Back");
addRow(panel,
charKey('`', '~', KeyEvent.KEYCODE_GRAVE),
charKey('1', '!', KeyEvent.KEYCODE_1),
charKey('2', '@', KeyEvent.KEYCODE_2),
charKey('3', '#', KeyEvent.KEYCODE_3),
charKey('4', '$', KeyEvent.KEYCODE_4),
charKey('5', '%', KeyEvent.KEYCODE_5),
charKey('6', '^', KeyEvent.KEYCODE_6),
charKey('7', '&', KeyEvent.KEYCODE_7),
charKey('8', '*', KeyEvent.KEYCODE_8),
charKey('9', '(', KeyEvent.KEYCODE_9),
charKey('0', ')', KeyEvent.KEYCODE_0),
charKey('-', '_', KeyEvent.KEYCODE_MINUS),
charKey('=', '+', KeyEvent.KEYCODE_EQUALS));
addRow(panel, "Tab",
charKey('q', 'Q', KeyEvent.KEYCODE_Q), charKey('w', 'W', KeyEvent.KEYCODE_W),
charKey('e', 'E', KeyEvent.KEYCODE_E), charKey('r', 'R', KeyEvent.KEYCODE_R),
charKey('t', 'T', KeyEvent.KEYCODE_T), charKey('y', 'Y', KeyEvent.KEYCODE_Y),
charKey('u', 'U', KeyEvent.KEYCODE_U), charKey('i', 'I', KeyEvent.KEYCODE_I),
charKey('o', 'O', KeyEvent.KEYCODE_O), charKey('p', 'P', KeyEvent.KEYCODE_P),
charKey('[', '{', KeyEvent.KEYCODE_LEFT_BRACKET),
charKey(']', '}', KeyEvent.KEYCODE_RIGHT_BRACKET),
charKey('\\', '|', KeyEvent.KEYCODE_BACKSLASH));
addRow(panel, "Ctrl",
charKey('a', 'A', KeyEvent.KEYCODE_A), charKey('s', 'S', KeyEvent.KEYCODE_S),
charKey('d', 'D', KeyEvent.KEYCODE_D), charKey('f', 'F', KeyEvent.KEYCODE_F),
charKey('g', 'G', KeyEvent.KEYCODE_G), charKey('h', 'H', KeyEvent.KEYCODE_H),
charKey('j', 'J', KeyEvent.KEYCODE_J), charKey('k', 'K', KeyEvent.KEYCODE_K),
charKey('l', 'L', KeyEvent.KEYCODE_L),
charKey(';', ':', KeyEvent.KEYCODE_SEMICOLON),
charKey('\'', '"', KeyEvent.KEYCODE_APOSTROPHE),
"Enter");
addRow(panel, "Shift",
charKey('z', 'Z', KeyEvent.KEYCODE_Z), charKey('x', 'X', KeyEvent.KEYCODE_X),
charKey('c', 'C', KeyEvent.KEYCODE_C), charKey('v', 'V', KeyEvent.KEYCODE_V),
charKey('b', 'B', KeyEvent.KEYCODE_B), charKey('n', 'N', KeyEvent.KEYCODE_N),
charKey('m', 'M', KeyEvent.KEYCODE_M),
charKey(',', '<', KeyEvent.KEYCODE_COMMA),
charKey('.', '>', KeyEvent.KEYCODE_PERIOD),
charKey('/', '?', KeyEvent.KEYCODE_SLASH),
"Up");
addRow(panel, "Alt", charKey(' ', ' ', KeyEvent.KEYCODE_SPACE), "Left", "Down", "Right");
return panel;
}
// The title bar: a decorative "drag here" label (not itself
// interactive - see the class doc, dragging is handled entirely by
// xr_input.c before a hit ever reaches here) plus a real Close button,
// which is dispatched as an ordinary click like any other key. Its
// height and the Close button's width must stay in sync with
// xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
private LinearLayout buildTitleBar() {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
// Matches MenuOverlay's own header bar style.
bar.setBackgroundColor(0xFF000080);
titleLabel = new TextView(activity);
titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
titleLabel.setTextColor(0xFFFFFFFF);
titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
titleLabel.setGravity(Gravity.CENTER_VERTICAL);
titleLabel.setPadding(24, 0, 0, 0);
bar.addView(titleLabel, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
closeButton = new Button(activity);
closeButton.setText(Strings.get("close", currentLanguage));
closeButton.setOnClickListener(v -> nativeRequestClose());
closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
closeButton.setBackground(buttonBackground());
closeButton.setTextColor(buttonTextColors());
registerHoverable(closeButton);
bar.addView(closeButton,
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
return bar;
}
// 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 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()).
private void addRow(LinearLayout panel, Object... items) {
LinearLayout row = new LinearLayout(activity);
row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) {
Button key = new Button(activity);
key.setBackground(buttonBackground());
key.setTextColor(buttonTextColors());
registerHoverable(key);
float weight;
if (item instanceof CharKey) {
CharKey charKey = (CharKey) item;
charKey.button = key;
key.setText(charKeyLabel(charKey, shiftActive));
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.
weight = (charKey.base == ' ') ? 4f : 1f;
} else {
String label = (String) item;
key.setText(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")) {
ctrlButton = key;
} else if (label.equals("Alt")) {
altButton = key;
}
}
row.addView(key, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, weight));
}
panel.addView(row, new LinearLayout.LayoutParams(
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")
|| label.equals("Alt")) {
return 1.5f;
}
return 1f;
}
// Maps a control key's label to the Android keycode forwarded via
// SDLActivity.onNativeKeyDown()/onNativeKeyUp() - the same pair a
// physical Bluetooth keyboard's key events already drive (see
// SDLActivity.handleKeyEvent()). F-keys are contiguous in
// android.view.KeyEvent (KEYCODE_F1..KEYCODE_F12), so "F1".."F12" are
// computed rather than listed individually.
private static int controlKeyCode(String label) {
switch (label) {
case "Back": return KeyEvent.KEYCODE_DEL;
case "Esc": return KeyEvent.KEYCODE_ESCAPE;
case "Enter": return KeyEvent.KEYCODE_ENTER;
case "Tab": return KeyEvent.KEYCODE_TAB;
case "Up": return KeyEvent.KEYCODE_DPAD_UP;
case "Down": return KeyEvent.KEYCODE_DPAD_DOWN;
case "Left": return KeyEvent.KEYCODE_DPAD_LEFT;
case "Right": return KeyEvent.KEYCODE_DPAD_RIGHT;
default:
if (label.charAt(0) == 'F') {
int n = Integer.parseInt(label.substring(1));
return KeyEvent.KEYCODE_F1 + (n - 1);
}
throw new IllegalArgumentException("no keycode for " + label);
}
}
// 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;
}
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);
if (label.equals("Back")) {
if (typedText.length() > 0) {
typedText.setLength(typedText.length() - 1);
}
} else {
typedText.append('[').append(label).append(']');
}
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 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);
} else {
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 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
// handled by releaseArmedModifiers() below, not here.
private void toggleModifier(String label) {
boolean ctrl = label.equals("Ctrl");
Button button = ctrl ? ctrlButton : altButton;
int keyCode = ctrl ? KeyEvent.KEYCODE_CTRL_LEFT : KeyEvent.KEYCODE_ALT_LEFT;
boolean nowArmed = ctrl ? !ctrlArmed : !altArmed;
if (ctrl) {
ctrlArmed = nowArmed;
} else {
altArmed = nowArmed;
}
if (nowArmed) {
SDLActivity.onNativeKeyDown(keyCode);
} else {
SDLActivity.onNativeKeyUp(keyCode);
}
button.setText(nowArmed ? "[" + label + "]" : label);
}
// 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);
ctrlArmed = false;
ctrlButton.setText("Ctrl");
}
if (altArmed) {
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_ALT_LEFT);
altArmed = false;
altButton.setText("Alt");
}
}
// Shift never itself reaches the game (see class doc) - it only flips
// which character each CharKey shows/sends, including its own label.
// Note this means a Ctrl+Shift+key chord won't carry Shift to the
// engine (Ctrl/Alt are real forwarded modifier keys, Shift here isn't) -
// not worth solving unless it actually comes up.
private void updateShiftVisual() {
shiftButton.setText(shiftActive ? "[Shift]" : "Shift");
for (CharKey key : charKeys) {
key.button.setText(charKeyLabel(key, shiftActive));
}
}
// 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 native void nativeSendPrintableCharDown(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
// the keyboard (see this class's doc comment and xr_input.c).
private static native void nativeRequestClose();
// Called from MenuOverlay's own periodic header-refresh timer when it
// detects the game's language preference changed mid-session (via its
// own options menu) - re-labels this panel's title bar chrome to match.
// A plain Java call (both classes are in this same package), not JNI -
// runOnUiThread() here is defensive/self-documenting more than
// strictly needed today, since MenuOverlay's own timer already runs on
// the UI thread itself (Activity.runOnUiThread() is a no-op wrapper
// when already on that thread).
public static void applyTranslations(final int language) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> {
instance.currentLanguage = language;
instance.titleLabel.setText(Strings.get("keyboard_drag_title", language));
instance.closeButton.setText(Strings.get("close", language));
instance.redrawContent();
});
}
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
// that was attached to the JVM rather than spawned from Java).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new KeyboardOverlay(activity);
}
}
public static byte[] nativeTakePixelsIfDirty() {
return instance == null ? null : instance.takePixelsIfDirty();
}
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance != null) {
instance.dispatchTouch(u, v, down);
}
}
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
if (instance != null) {
instance.dispatchUpdateCursor(u, v, visible);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,378 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* Shared machinery for an off-screen, never-attached-to-a-window View tree
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
* xr_overlay.c, which owns the quad's swapchain and drives whichever
* concrete subclass via JNI - construction, touch input, and pixel
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
* into an unattached hierarchy both work the same way they would for an
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
* ViewRootImpl/window, just a measured+laid-out tree.
*
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
* buildContent() and, if they want translucency, contentAlpha() - and, since
* Java has no static virtual dispatch and each overlay needs its own
* singleton, each subclass still declares its own thin nativeInit()/
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
* static methods (matching what xr_overlay.c's JNI glue resolves by class
* name) that just delegate into the instance methods here
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
*/
abstract class OverlayPanel {
private static final float CURSOR_RADIUS = 10f;
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
// calls View.post(mPerformClick) rather than invoking performClick()
// directly; post() queues the runnable to run once the view is attached
// to a window and returns true immediately even when unattached, so on
// this permanently-unattached tree that queued click silently never
// fires - dispatchTouchEvent() still delivers the down/up events
// correctly, only the click callback is swallowed. Registering this as
// each button's OnTouchListener bypasses that path entirely: returning
// true here skips View's internal onTouchEvent() (see
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
// runs any OnClickListener set via setOnClickListener()) is called
// directly instead.
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
v.setPressed(true);
break;
case MotionEvent.ACTION_UP:
v.setPressed(false);
v.performClick();
break;
case MotionEvent.ACTION_CANCEL:
v.setPressed(false);
break;
}
return true;
};
// A plain, instantly-drawn StateListDrawable rather than the platform
// default Button background (a Material ripple): ripples animate via
// continuous invalidation callbacks tied to a real attached window's
// vsync, which never fires here (this tree is never attached to a
// window) - so setPressed()/setHovered() state changes would otherwise
// produce no visible change at all, independent of this project's own
// touch handling (which already calls setPressed() correctly - see
// CLICK_ON_TOUCH_UP above). Every button this project creates
// (MenuOverlay, KeyboardOverlay) should use this (or
// MenuOverlay.selectableButtonBackground(), which adds a persistent
// "selected" state on top of the same normal/hovered/pressed colors) -
// paired with buttonTextColors() below, since the normal/hovered state
// is light enough to need dark text while pressed/selected need white.
protected static Drawable buttonBackground() {
StateListDrawable drawable = new StateListDrawable();
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
return drawable;
}
// See buttonBackground() above - light gray (normal/hovered) needs dark
// text; the accent colors (pressed, and MenuOverlay's own "selected"
// state) need white. Every button should get both this and
// buttonBackground()/selectableButtonBackground() together.
protected static ColorStateList buttonTextColors() {
return new ColorStateList(
new int[][]{
new int[]{android.R.attr.state_pressed},
new int[]{android.R.attr.state_selected},
new int[]{},
},
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
}
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
protected static final int PRESSED_COLOR = 0xFF3355AA;
protected final Activity activity;
private final int width;
private final int height;
private final View root;
// Views registered via registerHoverable() (every button, from both
// buildContent() and any later-added content) - hit-tested against the
// cursor position on every updateCursor() call so aiming at (not
// clicking) one visibly highlights it via android.R.attr.state_hovered
// (a real View state since API 14, paired with buttonBackground()
// above).
private final List<View> hoverableViews = new ArrayList<>();
private View hoveredView = null;
// contentBitmap holds just the panel's own rendered content (no cursor),
// redrawn only when that content actually changes (redrawContent(), via
// handleTouch() or a subclass's own key handling) via root.draw() - a
// full off-screen View-tree traversal that gets noticeably more
// expensive on a busier content tree. bitmap is what's actually
// published to native (see takePixelsIfDirty()): compositeAndPublish()
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
// these two apart matters because updateCursor() below fires every
// single frame (~90Hz) while aiming at the panel - if it called the
// expensive root.draw() path every time (as an earlier version did), a
// busy content tree made each redraw slow enough that runOnUiThread()
// posts piled up faster than the UI thread could drain them, so the
// cursor visibly lagged behind the controller's actual aim instead of
// tracking it.
private final Bitmap contentBitmap;
private final Canvas contentCanvas;
private final Bitmap bitmap;
private final Canvas canvas;
private final Paint cursorPaint;
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
private final Paint contentAlphaPaint;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
// compositeAndPublish() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
private float cursorY = 0f;
protected OverlayPanel(Activity activity, int width, int height) {
this.activity = activity;
this.width = width;
this.height = height;
root = buildContent();
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, width, height);
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
contentCanvas = new Canvas(contentBitmap);
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
cursorPaint = new Paint();
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
// contentAlpha() must not depend on any subclass instance state -
// it's called here, from the superclass constructor, before the
// subclass's own field initializers/constructor body have run (the
// usual Java construction order: this class's fields/constructor
// first, then the subclass's). A static constant is the only thing
// that's safe to return.
contentAlphaPaint = new Paint();
contentAlphaPaint.setAlpha(contentAlpha());
redrawContent();
}
// Builds this overlay's content View tree - called once, from this
// class's own constructor (see the note on contentAlpha() above: don't
// rely on subclass instance fields being initialized yet here; if a
// subclass needs its own mutable state available while building its
// content, e.g. a list of buttons to later relabel, initialize that
// field's value at the top of this method instead of via a field
// initializer).
protected abstract View buildContent();
// 0-255; defaults to fully opaque. Override for translucency (see the
// constructor's note on what's safe to depend on here).
protected int contentAlpha() {
return 255;
}
// Polled once per frame while this overlay's quad is visible (see
// xr_overlay_render_and_build_layer()) - returns the current pixels
// only once per redraw (null otherwise), so the native side knows when
// it can skip re-uploading a texture that hasn't actually changed.
protected final byte[] takePixelsIfDirty() {
synchronized (pixelLock) {
byte[] pixels = pendingPixels;
pendingPixels = null;
return pixels;
}
}
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
// ever touched from there.
protected final void dispatchTouch(final float u, final float v, final boolean down) {
activity.runOnUiThread(() -> handleTouch(u, v, down));
}
// u/v are the same hit-test coordinates dispatchTouch() uses, but
// polled once per frame regardless of click state rather than only on
// click edges - lets the cursor track the aim ray continuously instead
// of only jumping when a trigger is pressed. visible=false (no hand's
// ray currently on the quad) hides it.
protected final void dispatchUpdateCursor(final float u, final float v,
final boolean visible) {
activity.runOnUiThread(() -> updateCursor(u, v, visible));
}
private void updateCursor(float u, float v, boolean visible) {
float x = u * width;
float y = v * height;
if (visible == cursorVisible && x == cursorX && y == cursorY) {
return;
}
cursorVisible = visible;
cursorX = x;
cursorY = y;
if (updateHoverState(x, y, visible)) {
// redrawContent() (called inside updateHoverState()) already
// composited and published this frame's pixels - nothing more
// to do.
return;
}
compositeAndPublish();
}
// Registers a button (or any View) to be hit-tested against the cursor
// position - called once per button, alongside buttonBackground()/
// setOnTouchListener(), from buildContent() (or any later content
// rebuild).
protected final void registerHoverable(View view) {
hoverableViews.add(view);
}
// Clears whatever's currently hovered without waiting for the cursor to
// actually move off of it - call after hiding/showing content (e.g. a
// tab switch), since a view that was hovered right before becoming
// GONE would otherwise still be internally tracked as hovered (and
// wrongly show that way again) if it's later made VISIBLE again before
// the cursor position next changes.
protected final void resetHover() {
if (hoveredView != null) {
hoveredView.setHovered(false);
hoveredView = null;
}
}
// Absolute bounds of view within this panel's own coordinate space
// (i.e. relative to root, which is always laid out at (0,0,width,
// height) - see the constructor) - View's own getLeft()/getTop() are
// parent-relative, so this walks up the parent chain summing them,
// additionally subtracting each ancestor's own getScrollX()/getScrollY()
// (0 for a plain ViewGroup, non-zero for e.g. a ScrollView - see
// MenuOverlay's Controller Options tab) so a view inside a scrolled
// container reports its actual on-screen position, not its laid-out one.
// Assumes view is actually part of this panel's tree (every registered
// view always is, since registerHoverable() is only ever called from
// buildContent()).
private Rect absoluteBounds(View view) {
int left = 0;
int top = 0;
View current = view;
while (true) {
left += current.getLeft();
top += current.getTop();
if (current == root || !(current.getParent() instanceof View)) {
break;
}
View parent = (View) current.getParent();
left -= parent.getScrollX();
top -= parent.getScrollY();
current = parent;
}
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
}
// Returns true if the hovered view changed (and so redrawContent() -
// which always publishes too - already ran); false if there's nothing
// more for updateCursor() to do beyond its own compositeAndPublish().
private boolean updateHoverState(float x, float y, boolean visible) {
View newHovered = null;
if (visible) {
for (View candidate : hoverableViews) {
if (candidate.getVisibility() != View.VISIBLE) {
continue;
}
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
newHovered = candidate;
break;
}
}
}
if (newHovered == hoveredView) {
return false;
}
if (hoveredView != null) {
hoveredView.setHovered(false);
}
if (newHovered != null) {
newHovered.setHovered(true);
}
hoveredView = newHovered;
redrawContent();
return true;
}
private void handleTouch(float u, float v, boolean down) {
float x = u * width;
float y = v * height;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
redrawContent();
}
// Re-runs the full off-screen View-tree draw (expensive - see the
// contentBitmap field comment above) - call only when the panel's
// actual content changed, not for the cursor-only updates
// compositeAndPublish() below handles on its own.
protected final void redrawContent() {
contentCanvas.drawColor(0xFF202020);
root.draw(contentCanvas);
compositeAndPublish();
}
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
// traversal) plus the cursor circle into bitmap and publishes it.
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
// background first); contentAlpha() translucency is applied right here
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
// transparent first, since otherwise each frame's alpha-blended draw
// would blend against whatever was left over in bitmap from the
// previous frame rather than against nothing, compounding into a
// ghosting trail across frames.
private void compositeAndPublish() {
bitmap.eraseColor(0);
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
if (cursorVisible) {
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
}
byte[] pixels = new byte[width * height * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
}
}
}
@@ -0,0 +1,292 @@
package de.ladkau.questshock;
import android.Manifest;
import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import androidx.core.app.ActivityCompat;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/**
* Prepares /sdcard/QuestShock/ the way Shockolate's plain relative-path
* file I/O ("res/data/...", confirmed via grep - none of it goes through
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for
* why this is done from Java instead of patching engine/ itself, which is
* never modified.
*
* super.onCreate() (SDLActivity's) must always run first and unconditionally
* - Android throws SuperNotCalledException otherwise, checked right after
* onCreate() returns, regardless of what this subclass does afterwards.
* SDLActivity's own onCreate() only loads libraries, sets up JNI and the
* surface - the actual native SDL_main thread doesn't start until later, from
* one of several lifecycle paths (onResume(), onWindowFocusChanged(), and
* SDLSurface.surfaceChanged() - see GameSurface below for why that last one
* needs its own fix) - so it's safe to do our own checks (and set
* SDLActivity.mBrokenLibraries, which most - but not all - of those paths
* gate on) afterwards.
*/
public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1;
// Package-visible: MenuOverlay reads controller_profiles/ from here.
static final String GAME_DIR = "/sdcard/QuestShock";
// Loaded here (rather than waiting for SDLActivity's own
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
// purely to get nativeChdir() below - Android's dynamic linker resolves
// "main"'s own dependencies (SDL2, SDL2_mixer, fluidsynth) from the
// APK's native library directory regardless of Java-side load order, sox
// loading it early here is safe. SDLActivity loading "main" again later
// is a harmless no-op (System.loadLibrary is idempotent per
// ClassLoader).
static {
System.loadLibrary("main");
}
// See android/app/src/main/cpp/questshock_native.c - android.system.Os
// has no public chdir() (confirmed against the actual API 34 stub jar).
private static native void nativeChdir(String path);
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
@Override
protected String[] getLibraries() {
return new String[] {
"SDL2",
"SDL2_mixer",
"fluidsynth",
"main"
};
}
// SDLSurface.surfaceChanged() (org/libsdl/app/, vendored from SDL2's own
// template) starts the native SDL thread directly - unlike onResume()/
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
// first. Since surfaceChanged() fires on essentially every launch
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
// setUpGameDirAndContinue()) does not actually stop the engine from
// starting. Route through a subclass that adds the missing check instead
// of patching the vendored file directly.
private static class GameSurface extends SDLSurface {
GameSurface(Context context) {
super(context);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
if (SDLActivity.mBrokenLibraries) {
return;
}
super.surfaceChanged(holder, format, width, height);
}
}
@Override
protected SDLSurface createSDLSurface(Context context) {
return new GameSurface(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (SDLActivity.mBrokenLibraries) {
// SDLActivity's own onCreate() already showed its "SDL Error"
// dialog for this - nothing left for us to do.
return;
}
// Provisionally block the native engine from starting - cleared only
// once setUpGameDirAndContinue() confirms game data is present. Must
// happen before requestPermissions() below: the storage-permission
// dialog closing can fire onWindowFocusChanged(true) - which starts
// the native SDLThread - before the async onRequestPermissionsResult()
// callback (which is what actually calls setUpGameDirAndContinue())
// gets a chance to run, so mBrokenLibraries has to already be true
// going into that race, not set afterwards.
SDLActivity.mBrokenLibraries = true;
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[] { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE },
PERMISSION_REQUEST_STORAGE);
return;
}
setUpGameDirAndContinue();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
if (requestCode == PERMISSION_REQUEST_STORAGE) {
setUpGameDirAndContinue();
return;
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
private void setUpGameDirAndContinue() {
File gameDir = new File(GAME_DIR);
if (!gameDir.exists() && !gameDir.mkdirs()) {
Log.e(TAG, "Could not create " + GAME_DIR + " - res/data and res/sound will not be found");
}
// Extract our own bundled build artifacts (shaders, the soundfont,
// and the "get assets" instructions) - never the proprietary game
// data, which the user must supply themselves (see
// res/GET_ASSETS_QUEST.txt). Only done once: if res/GET_ASSETS_QUEST.txt
// is already there, assume a previous run already extracted everything.
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
if (!marker.exists()) {
copyAssetDir("shaders", new File(gameDir, "shaders"), false);
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false);
copyAssetFile("GET_ASSETS_QUEST.txt", marker, false);
}
// Refreshed on every launch (unlike the marker-gated block above) so
// it always matches the installed build - qs_config.c reads this as
// the initial config only when questshock.config.json doesn't exist
// yet, never overwriting a user's own saved config.
copyAssetFile("questshock.config.default.json",
new File(gameDir, "questshock.config.default.json"), true);
// Refreshed on every launch. MenuOverlay's Controller Options tab
// matches the active OpenXR interaction profile against these files'
// "interaction_profile" and shows the paired image (see CONFIG.md).
// Only the bundled filenames are overwritten, so profiles a user adds
// to this folder are kept.
copyAssetDir("controller_profiles", new File(gameDir, "controller_profiles"), true);
// Shockolate's engine/ is never patched to check this itself (see the
// class doc above) - it just does plain fopen("res/data/...", ...)
// and, on a fresh install with no game data copied in yet, that
// fails deep inside startup (init_popups(), which doesn't NULL-check
// the load) as a hard native crash instead of a message. Catch the
// missing-data case here instead, before Shockolate's native
// SDL_main ever starts.
if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) {
// mBrokenLibraries is already true (set in onCreate()) - leave it
// that way. Every path that starts the native SDL_main thread
// (onWindowFocusChanged(), resumeNativeThread(), etc.) already
// checks this flag before doing anything native, so this
// reliably prevents the engine from starting without having to
// duplicate all of SDLActivity's own lifecycle guards ourselves.
showMissingAssetsDialog();
return;
}
// chdir() is process-wide, not per-thread - already in effect for
// every thread (including the one that will run Shockolate's own
// SDL_main) by the time the native thread actually starts. Done
// before clearing mBrokenLibraries below so the engine can never
// start pre-chdir.
nativeChdir(GAME_DIR);
loadSplashImage();
// Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
}
// The engine shows this at startup (qs_splash.c); without it there is no splash.
private void loadSplashImage() {
try (InputStream in = getAssets().open("splash.png")) {
Bitmap bitmap = BitmapFactory.decodeStream(in);
if (bitmap == null) {
Log.e(TAG, "Could not decode splash.png");
return;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
bitmap.recycle();
nativeSetSplashImage(pixels, width, height);
} catch (IOException e) {
Log.e(TAG, "Could not read splash.png", e);
}
}
private static boolean isNonEmptyDir(File dir) {
String[] entries = dir.list();
return entries != null && entries.length > 0;
}
// Same single-button, non-cancelable pattern as SDLActivity's own
// "broken libraries" dialog (org/libsdl/app/SDLActivity.java) - there's
// no game to start without this data, so the only way forward is to
// close, copy the assets, and relaunch. Message text mirrors
// res/assets/GET_ASSETS_QUEST.txt (also extracted to GAME_DIR) so the
// user isn't sent hunting for a second file just to read it the first
// time - it points back there at the end in case they need it again.
private void showMissingAssetsDialog() {
new AlertDialog.Builder(this)
.setTitle("Game data missing")
.setMessage("This app does not include System Shock's game data - it's "
+ "copyrighted, proprietary content that can't be redistributed. To "
+ "play, you need to own a copy of System Shock: Enhanced Edition (e.g. "
+ "from gog.com), installed (on Windows, or via Wine/Proton on Linux).\n\n"
+ "Using SideQuest (or any MTP file browser) with your Quest connected, "
+ "copy its res/data/ and res/sound/ folders directly into:\n"
+ GAME_DIR + "/res/data/\n"
+ GAME_DIR + "/res/sound/\n"
+ "They already contain everything needed, in the right layout - no "
+ "merging or extraction required.\n\n"
+ "Then relaunch.\n\n"
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
+ "QuestShock folder on this device, if you need to read them again.)")
.setCancelable(false)
.setPositiveButton("Exit", (dialog, which) -> finish())
.create()
.show();
}
private void copyAssetFile(String assetPath, File dest, boolean overwrite) {
if (dest.exists() && !overwrite) {
return;
}
File parent = dest.getParentFile();
if (parent != null) {
parent.mkdirs();
}
AssetManager assets = getAssets();
try (InputStream in = assets.open(assetPath);
OutputStream out = new FileOutputStream(dest)) {
byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
out.write(buf, 0, n);
}
} catch (IOException e) {
Log.e(TAG, "Could not extract asset " + assetPath + " to " + dest, e);
}
}
private void copyAssetDir(String assetDir, File destDir, boolean overwrite) {
AssetManager assets = getAssets();
String[] entries;
try {
entries = assets.list(assetDir);
} catch (IOException e) {
Log.e(TAG, "Could not list asset dir " + assetDir, e);
return;
}
if (entries == null || entries.length == 0) {
return;
}
destDir.mkdirs();
for (String entry : entries) {
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), overwrite);
}
}
}
@@ -0,0 +1,217 @@
package de.ladkau.questshock;
import java.util.HashMap;
import java.util.Map;
/**
* Best-effort (not professionally reviewed) translations for this app's own
* UI chrome text - the menu/keyboard panels this project draws itself, not
* the game's own resource-string text (which already has its own separate
* English/French/German data files). Selected by gShockPrefs.goLanguage (0
* English/1 French/2 German, Prefs.h) via MenuOverlay's native language
* query (qs_config_current_language()) - see MenuOverlay's periodic header-
* refresh timer, which also pushes it to KeyboardOverlay.
*
* Deliberately excludes the keyboard's own alphanumeric key labels (a-z,
* Tab, Space, Esc, ...) - those represent physical key positions/characters
* rather than translatable UI copy - and the restart-confirmation dialog's
* message text, which is built in native code (qs_config.c's
* qs_show_restart_dialog()) with an interpolated option-name list and is
* not currently reachable in practice (see CONFIG.md - neither Video
* Options option is flagged restart-required today).
*/
final class Strings {
static final int ENGLISH = 0;
static final int FRENCH = 1;
static final int GERMAN = 2;
private static final Map<String, String[]> TABLE = new HashMap<>();
private static void put(String key, String en, String fr, String de) {
TABLE.put(key, new String[]{en, fr, de});
}
static {
put("keyboard", "Keyboard", "Clavier", "Tastatur");
put("save_configuration", "Save Configuration", "Enregistrer la configuration",
"Konfiguration speichern");
put("load_configuration", "Load Configuration", "Charger la configuration",
"Konfiguration laden");
put("reset_position", "Reset Position", "Réinitialiser la position",
"Position zurücksetzen");
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
put("dismiss", "Dismiss", "Fermer", "Schließen");
put("restart_now", "Restart Now", "Redémarrer maintenant", "Jetzt neu starten");
put("renderer", "Renderer", "Moteur de rendu", "Renderer");
put("resolution", "Resolution", "Résolution", "Auflösung");
put("software", "Software", "Logiciel", "Software");
put("language", "Language", "Langue", "Sprache");
put("english", "English", "Anglais", "Englisch");
put("french", "French", "Français", "Französisch");
put("german", "German", "Allemand", "Deutsch");
put("initial_screen", "Initial Screen", "Écran initial", "Startbildschirm");
put("game_screen", "Game Screen", "Écran de jeu", "Spielbildschirm");
put("width", "Width", "Largeur", "Breite");
put("distance", "Distance", "Distance", "Abstand");
// "OpenGL" is a proper/brand name - not translated, same in every
// language, so it has no entry here; call sites just use it as a
// literal.
put("keyboard_drag_title", "Keyboard - drag here to move",
"Clavier - faites glisser ici pour déplacer",
"Tastatur - hier ziehen zum Verschieben");
put("close", "Close", "Fermer", "Schließen");
// Controller Options tab: action row labels, keyed by xr_input.c's
// action names (see MenuOverlay.ACTION_KEYS).
put("controller_options", "Controller Options", "Options de manette", "Controller-Optionen");
put("action_move_forward", "Move Forward", "Avancer", "Vorwärts gehen");
put("action_move_back", "Move Back", "Reculer", "Rückwärts gehen");
put("action_strafe_left", "Strafe Left", "Pas latéral gauche", "Seitwärts links");
put("action_strafe_right", "Strafe Right", "Pas latéral droit", "Seitwärts rechts");
put("action_turn_left", "Turn Left", "Tourner à gauche", "Nach links drehen");
put("action_turn_right", "Turn Right", "Tourner à droite", "Nach rechts drehen");
put("action_jump", "Jump", "Sauter", "Springen");
put("action_mouse_click", "Click", "Cliquer", "Klicken");
put("action_mouse_double_click", "Double-Click", "Double-clic", "Doppelklick");
// Hotkey actions (see xr_input.c); all unbound by default.
put("action_stand", "Stand", "Debout", "Stehen");
put("action_crouch", "Crouch", "Accroupi", "Ducken");
put("action_prone", "Prone", "Couché", "Liegen");
put("action_pause", "Pause Game", "Mettre en pause", "Spiel pausieren");
put("action_full_view", "Fullscreen View", "Vue plein écran", "Vollbildansicht");
put("action_normal_view", "Normal View", "Vue normale", "Normale Ansicht");
put("action_map_view", "Automap View", "Vue de la carte", "Automap-Ansicht");
put("action_save_game", "Save Game", "Sauvegarder", "Spiel speichern");
put("action_load_game", "Load Game", "Charger", "Spiel laden");
put("action_select_grenade", "Select Grenade", "Sélectionner une grenade",
"Granate auswählen");
put("action_arm_grenade", "Arm Grenade", "Armer la grenade", "Granate scharfmachen");
put("action_select_drug", "Select Drug", "Sélectionner une drogue", "Droge auswählen");
put("action_use_drug", "Use Drug", "Utiliser la drogue", "Droge benutzen");
put("action_toggle_online_help", "Toggle Online Help", "Basculer l'aide en ligne",
"Online-Hilfe umschalten");
put("action_show_help", "Show Help", "Afficher l'aide", "Hilfe anzeigen");
put("action_toggle_music", "Toggle Music", "Basculer la musique", "Musik umschalten");
put("action_quit", "Quit Game", "Quitter le jeu", "Spiel beenden");
put("action_cycle_weapon_next", "Next Weapon", "Arme suivante", "Nächste Waffe");
put("action_cycle_weapon_prev", "Previous Weapon", "Arme précédente", "Vorherige Waffe");
put("action_cycle_detail", "Cycle Detail Level", "Changer le niveau de détail",
"Detailgrad wechseln");
put("action_reload_weapon_primary", "Reload Weapon (Primary)",
"Recharger l'arme (principal)", "Waffe nachladen (primär)");
put("action_reload_weapon_secondary", "Reload Weapon (Secondary)",
"Recharger l'arme (secondaire)", "Waffe nachladen (sekundär)");
put("action_hud_color", "Cycle HUD Color", "Changer la couleur de l'interface",
"HUD-Farbe wechseln");
put("action_clear_fullscreen", "Clear Fullscreen View", "Quitter la vue plein écran",
"Vollbildansicht schließen");
put("action_mfd_left_1", "Left MFD: Weapon", "MFD gauche : arme", "Linkes MFD: Waffe");
put("action_mfd_left_2", "Left MFD: Item", "MFD gauche : objet", "Linkes MFD: Gegenstand");
put("action_mfd_left_3", "Left MFD: Map", "MFD gauche : carte", "Linkes MFD: Karte");
put("action_mfd_left_4", "Left MFD: Target", "MFD gauche : cible", "Linkes MFD: Ziel");
put("action_mfd_left_5", "Left MFD: Info", "MFD gauche : info", "Linkes MFD: Info");
put("action_mfd_right_1", "Right MFD: Weapon", "MFD droit : arme", "Rechtes MFD: Waffe");
put("action_mfd_right_2", "Right MFD: Item", "MFD droit : objet",
"Rechtes MFD: Gegenstand");
put("action_mfd_right_3", "Right MFD: Map", "MFD droit : carte", "Rechtes MFD: Karte");
put("action_mfd_right_4", "Right MFD: Target", "MFD droit : cible", "Rechtes MFD: Ziel");
put("action_mfd_right_5", "Right MFD: Info", "MFD droit : info", "Rechtes MFD: Info");
put("action_fire_weapon", "Fire Weapon", "Tirer", "Waffe abfeuern");
// Cheats tab.
put("cheats", "Cheats", "Triches", "Cheats");
put("cheats_note", "Cheats need a running game.", "Les triches nécessitent une partie en cours.",
"Cheats benötigen ein laufendes Spiel.");
put("cheat_give_all", "Give All Items & Weapons", "Tout obtenir", "Alles erhalten");
put("cheat_physics", "Physics", "Physique", "Physik");
put("cheat_level_up", "Next Level", "Niveau suivant", "Nächstes Level");
put("cheat_level_down", "Previous Level", "Niveau précédent", "Vorheriges Level");
put("cheat_unlimited_health", "Unlimited Health", "Santé illimitée", "Unbegrenzt Leben");
put("cheat_unlimited_energy", "Unlimited Energy", "Énergie illimitée", "Unbegrenzt Energie");
put("cheat_unlimited_ammo", "Unlimited Ammo", "Munitions illimitées", "Unbegrenzt Munition");
put("cheat_unlimited_items", "Unlimited Items", "Objets illimités", "Unbegrenzt Items");
put("action_free_left_thumbstick_view", "Free View (Left Stick)",
"Vue libre (stick gauche)", "Freie Ansicht (linker Stick)");
put("action_free_right_thumbstick_view", "Free View (Right Stick)",
"Vue libre (stick droit)", "Freie Ansicht (rechter Stick)");
// Cyberspace actions (active only in cyberspace, see xr_input.c).
put("action_cyber_thrust", "Cyberspace: Thrust", "Cyberespace : poussée",
"Cyberspace: Schub");
put("action_cyber_climb", "Cyberspace: Climb", "Cyberespace : monter",
"Cyberspace: Steigen");
put("action_cyber_dive", "Cyberspace: Dive", "Cyberespace : plonger",
"Cyberspace: Sinken");
put("action_cyber_bank_left", "Cyberspace: Bank Left", "Cyberespace : virage à gauche",
"Cyberspace: Links kippen");
put("action_cyber_bank_right", "Cyberspace: Bank Right", "Cyberespace : virage à droite",
"Cyberspace: Rechts kippen");
put("action_cyber_roll_left", "Cyberspace: Roll Left", "Cyberespace : roulis gauche",
"Cyberspace: Links rollen");
put("action_cyber_roll_right", "Cyberspace: Roll Right", "Cyberespace : roulis droit",
"Cyberspace: Rechts rollen");
put("action_cyber_climb_left", "Cyberspace: Climb Left", "Cyberespace : monter à gauche",
"Cyberspace: Links steigen");
put("action_cyber_climb_right", "Cyberspace: Climb Right", "Cyberespace : monter à droite",
"Cyberspace: Rechts steigen");
put("action_cyber_dive_left", "Cyberspace: Dive Left", "Cyberespace : plonger à gauche",
"Cyberspace: Links sinken");
put("action_cyber_dive_right", "Cyberspace: Dive Right", "Cyberespace : plonger à droite",
"Cyberspace: Rechts sinken");
put("capture_prompt", "Press a button…", "Appuyez sur un bouton…", "Taste drücken…");
put("reset_to_defaults", "Reset to Defaults", "Réinitialiser", "Auf Standard zurücksetzen");
put("generic_profile_note",
"This controller only exposes a trigger and a menu button - most actions above have "
+ "nothing to bind to.",
"Cette manette n'expose qu'une gâchette et un bouton menu - la plupart des actions "
+ "ci-dessus n'ont rien à quoi être assignées.",
"Dieser Controller bietet nur einen Trigger und eine Menütaste - die meisten "
+ "Aktionen oben haben nichts, woran sie gebunden werden können.");
// Raw physical-input display names (xr_input.c's kRawInputNames);
// "none" is the unbound sentinel.
put("raw_left_trigger", "Left Trigger", "Gâchette gauche", "Linker Trigger");
put("raw_right_trigger", "Right Trigger", "Gâchette droite", "Rechter Trigger");
put("raw_left_squeeze", "Left Grip", "Préhension gauche", "Linker Griff");
put("raw_right_squeeze", "Right Grip", "Préhension droite", "Rechter Griff");
put("raw_left_x", "X Button", "Bouton X", "X-Taste");
put("raw_left_y", "Y Button", "Bouton Y", "Y-Taste");
put("raw_right_a", "A Button", "Bouton A", "A-Taste");
put("raw_right_b", "B Button", "Bouton B", "B-Taste");
put("raw_left_thumbstick_click", "Left Stick Click", "Clic du stick gauche",
"Linker Stick-Klick");
put("raw_right_thumbstick_click", "Right Stick Click", "Clic du stick droit",
"Rechter Stick-Klick");
put("raw_left_thumbstick_up", "Left Stick Up", "Stick gauche haut", "Linker Stick hoch");
put("raw_left_thumbstick_down", "Left Stick Down", "Stick gauche bas", "Linker Stick runter");
put("raw_left_thumbstick_left", "Left Stick Left", "Stick gauche à gauche",
"Linker Stick links");
put("raw_left_thumbstick_right", "Left Stick Right", "Stick gauche à droite",
"Linker Stick rechts");
put("raw_right_thumbstick_up", "Right Stick Up", "Stick droit haut", "Rechter Stick hoch");
put("raw_right_thumbstick_down", "Right Stick Down", "Stick droit bas",
"Rechter Stick runter");
put("raw_right_thumbstick_left", "Right Stick Left", "Stick droit à gauche",
"Rechter Stick links");
put("raw_right_thumbstick_right", "Right Stick Right", "Stick droit à droite",
"Rechter Stick rechts");
put("raw_trigger", "Either Trigger", "L'une ou l'autre gâchette", "Beliebiger Trigger");
put("raw_squeeze", "Either Grip", "L'une ou l'autre préhension", "Beliebiger Griff");
put("raw_none", "(unbound)", "(non assigné)", "(nicht zugewiesen)");
}
private Strings() {}
static String get(String key, int language) {
String[] variants = TABLE.get(key);
if (variants == null) {
return key;
}
if (language < 0 || language >= variants.length) {
language = ENGLISH;
}
return variants[language];
}
}
@@ -0,0 +1,22 @@
package org.libsdl.app;
import android.hardware.usb.UsbDevice;
interface HIDDevice
{
public int getId();
public int getVendorId();
public int getProductId();
public String getSerialNumber();
public int getVersion();
public String getManufacturerName();
public String getProductName();
public UsbDevice getDevice();
public boolean open();
public int sendFeatureReport(byte[] report);
public int sendOutputReport(byte[] report);
public boolean getFeatureReport(byte[] report);
public void setFrozen(boolean frozen);
public void close();
public void shutdown();
}
@@ -0,0 +1,650 @@
package org.libsdl.app;
import android.content.Context;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothGattService;
import android.hardware.usb.UsbDevice;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.os.*;
//import com.android.internal.util.HexDump;
import java.lang.Runnable;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.UUID;
class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
private static final String TAG = "hidapi";
private HIDDeviceManager mManager;
private BluetoothDevice mDevice;
private int mDeviceId;
private BluetoothGatt mGatt;
private boolean mIsRegistered = false;
private boolean mIsConnected = false;
private boolean mIsChromebook = false;
private boolean mIsReconnecting = false;
private boolean mFrozen = false;
private LinkedList<GattOperation> mOperations;
GattOperation mCurrentOperation = null;
private Handler mHandler;
private static final int TRANSPORT_AUTO = 0;
private static final int TRANSPORT_BREDR = 1;
private static final int TRANSPORT_LE = 2;
private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
static class GattOperation {
private enum Operation {
CHR_READ,
CHR_WRITE,
ENABLE_NOTIFICATION
}
Operation mOp;
UUID mUuid;
byte[] mValue;
BluetoothGatt mGatt;
boolean mResult = true;
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
mGatt = gatt;
mOp = operation;
mUuid = uuid;
}
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
mGatt = gatt;
mOp = operation;
mUuid = uuid;
mValue = value;
}
public void run() {
// This is executed in main thread
BluetoothGattCharacteristic chr;
switch (mOp) {
case CHR_READ:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Reading characteristic " + chr.getUuid());
if (!mGatt.readCharacteristic(chr)) {
Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
mResult = false;
break;
}
mResult = true;
break;
case CHR_WRITE:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
chr.setValue(mValue);
if (!mGatt.writeCharacteristic(chr)) {
Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
mResult = false;
break;
}
mResult = true;
break;
case ENABLE_NOTIFICATION:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Writing descriptor of " + chr.getUuid());
if (chr != null) {
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (cccd != null) {
int properties = chr.getProperties();
byte[] value;
if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
} else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
} else {
Log.e(TAG, "Unable to start notifications on input characteristic");
mResult = false;
return;
}
mGatt.setCharacteristicNotification(chr, true);
cccd.setValue(value);
if (!mGatt.writeDescriptor(cccd)) {
Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
mResult = false;
return;
}
mResult = true;
}
}
}
}
public boolean finish() {
return mResult;
}
private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
BluetoothGattService valveService = mGatt.getService(steamControllerService);
if (valveService == null)
return null;
return valveService.getCharacteristic(uuid);
}
static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
return new GattOperation(gatt, Operation.CHR_READ, uuid);
}
static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
}
static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
}
}
public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
mManager = manager;
mDevice = device;
mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
mIsRegistered = false;
mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
mOperations = new LinkedList<GattOperation>();
mHandler = new Handler(Looper.getMainLooper());
mGatt = connectGatt();
// final HIDDeviceBLESteamController finalThis = this;
// mHandler.postDelayed(new Runnable() {
// @Override
// public void run() {
// finalThis.checkConnectionForChromebookIssue();
// }
// }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
}
public String getIdentifier() {
return String.format("SteamController.%s", mDevice.getAddress());
}
public BluetoothGatt getGatt() {
return mGatt;
}
// Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
// of TRANSPORT_LE. Let's force ourselves to connect low energy.
private BluetoothGatt connectGatt(boolean managed) {
if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
try {
return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
} catch (Exception e) {
return mDevice.connectGatt(mManager.getContext(), managed, this);
}
} else {
return mDevice.connectGatt(mManager.getContext(), managed, this);
}
}
private BluetoothGatt connectGatt() {
return connectGatt(false);
}
protected int getConnectionState() {
Context context = mManager.getContext();
if (context == null) {
// We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
return BluetoothProfile.STATE_DISCONNECTED;
}
BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
if (btManager == null) {
// This device doesn't support Bluetooth. We should never be here, because how did
// we instantiate a device to start with?
return BluetoothProfile.STATE_DISCONNECTED;
}
return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
}
public void reconnect() {
if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
mGatt.disconnect();
mGatt = connectGatt();
}
}
protected void checkConnectionForChromebookIssue() {
if (!mIsChromebook) {
// We only do this on Chromebooks, because otherwise it's really annoying to just attempt
// over and over.
return;
}
int connectionState = getConnectionState();
switch (connectionState) {
case BluetoothProfile.STATE_CONNECTED:
if (!mIsConnected) {
// We are in the Bad Chromebook Place. We can force a disconnect
// to try to recover.
Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
}
else if (!isRegistered()) {
if (mGatt.getServices().size() > 0) {
Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
probeService(this);
}
else {
Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
}
}
else {
Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
return;
}
break;
case BluetoothProfile.STATE_DISCONNECTED:
Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
case BluetoothProfile.STATE_CONNECTING:
Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
break;
}
final HIDDeviceBLESteamController finalThis = this;
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
finalThis.checkConnectionForChromebookIssue();
}
}, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
}
private boolean isRegistered() {
return mIsRegistered;
}
private void setRegistered() {
mIsRegistered = true;
}
private boolean probeService(HIDDeviceBLESteamController controller) {
if (isRegistered()) {
return true;
}
if (!mIsConnected) {
return false;
}
Log.v(TAG, "probeService controller=" + controller);
for (BluetoothGattService service : mGatt.getServices()) {
if (service.getUuid().equals(steamControllerService)) {
Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
if (chr.getUuid().equals(inputCharacteristic)) {
Log.v(TAG, "Found input characteristic");
// Start notifications
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (cccd != null) {
enableNotification(chr.getUuid());
}
}
}
return true;
}
}
if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
mIsConnected = false;
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
}
return false;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
private void finishCurrentGattOperation() {
GattOperation op = null;
synchronized (mOperations) {
if (mCurrentOperation != null) {
op = mCurrentOperation;
mCurrentOperation = null;
}
}
if (op != null) {
boolean result = op.finish(); // TODO: Maybe in main thread as well?
// Our operation failed, let's add it back to the beginning of our queue.
if (!result) {
mOperations.addFirst(op);
}
}
executeNextGattOperation();
}
private void executeNextGattOperation() {
synchronized (mOperations) {
if (mCurrentOperation != null)
return;
if (mOperations.isEmpty())
return;
mCurrentOperation = mOperations.removeFirst();
}
// Run in main thread
mHandler.post(new Runnable() {
@Override
public void run() {
synchronized (mOperations) {
if (mCurrentOperation == null) {
Log.e(TAG, "Current operation null in executor?");
return;
}
mCurrentOperation.run();
// now wait for the GATT callback and when it comes, finish this operation
}
}
});
}
private void queueGattOperation(GattOperation op) {
synchronized (mOperations) {
mOperations.add(op);
}
executeNextGattOperation();
}
private void enableNotification(UUID chrUuid) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
queueGattOperation(op);
}
public void writeCharacteristic(UUID uuid, byte[] value) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
queueGattOperation(op);
}
public void readCharacteristic(UUID uuid) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
queueGattOperation(op);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
////////////// BluetoothGattCallback overridden methods
//////////////////////////////////////////////////////////////////////////////////////////////////////
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
//Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
mIsReconnecting = false;
if (newState == 2) {
mIsConnected = true;
// Run directly, without GattOperation
if (!isRegistered()) {
mHandler.post(new Runnable() {
@Override
public void run() {
mGatt.discoverServices();
}
});
}
}
else if (newState == 0) {
mIsConnected = false;
}
// Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
}
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
//Log.v(TAG, "onServicesDiscovered status=" + status);
if (status == 0) {
if (gatt.getServices().size() == 0) {
Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
mIsReconnecting = true;
mIsConnected = false;
gatt.disconnect();
mGatt = connectGatt(false);
}
else {
probeService(this);
}
}
}
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
//Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue());
}
finishCurrentGattOperation();
}
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
//Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
if (characteristic.getUuid().equals(reportCharacteristic)) {
// Only register controller with the native side once it has been fully configured
if (!isRegistered()) {
Log.v(TAG, "Registering Steam Controller with ID: " + getId());
mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0);
setRegistered();
}
}
finishCurrentGattOperation();
}
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
// Enable this for verbose logging of controller input reports
//Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
}
}
public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
//Log.v(TAG, "onDescriptorRead status=" + status);
}
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
//Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
if (chr.getUuid().equals(inputCharacteristic)) {
boolean hasWrittenInputDescriptor = true;
BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
if (reportChr != null) {
Log.v(TAG, "Writing report characteristic to enter valve mode");
reportChr.setValue(enterValveMode);
gatt.writeCharacteristic(reportChr);
}
}
finishCurrentGattOperation();
}
public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
//Log.v(TAG, "onReliableWriteCompleted status=" + status);
}
public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
//Log.v(TAG, "onReadRemoteRssi status=" + status);
}
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
//Log.v(TAG, "onMtuChanged status=" + status);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////// Public API
//////////////////////////////////////////////////////////////////////////////////////////////////////
@Override
public int getId() {
return mDeviceId;
}
@Override
public int getVendorId() {
// Valve Corporation
final int VALVE_USB_VID = 0x28DE;
return VALVE_USB_VID;
}
@Override
public int getProductId() {
// We don't have an easy way to query from the Bluetooth device, but we know what it is
final int D0G_BLE2_PID = 0x1106;
return D0G_BLE2_PID;
}
@Override
public String getSerialNumber() {
// This will be read later via feature report by Steam
return "12345";
}
@Override
public int getVersion() {
return 0;
}
@Override
public String getManufacturerName() {
return "Valve Corporation";
}
@Override
public String getProductName() {
return "Steam Controller";
}
@Override
public UsbDevice getDevice() {
return null;
}
@Override
public boolean open() {
return true;
}
@Override
public int sendFeatureReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return -1;
}
// We need to skip the first byte, as that doesn't go over the air
byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
//Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report));
writeCharacteristic(reportCharacteristic, actual_report);
return report.length;
}
@Override
public int sendOutputReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return -1;
}
//Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report));
writeCharacteristic(reportCharacteristic, report);
return report.length;
}
@Override
public boolean getFeatureReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return false;
}
//Log.v(TAG, "getFeatureReport");
readCharacteristic(reportCharacteristic);
return true;
}
@Override
public void close() {
}
@Override
public void setFrozen(boolean frozen) {
mFrozen = frozen;
}
@Override
public void shutdown() {
close();
BluetoothGatt g = mGatt;
if (g != null) {
g.disconnect();
g.close();
mGatt = null;
}
mManager = null;
mIsRegistered = false;
mIsConnected = false;
mOperations.clear();
}
}
@@ -0,0 +1,684 @@
package org.libsdl.app;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.os.Build;
import android.util.Log;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.hardware.usb.*;
import android.os.Handler;
import android.os.Looper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
public class HIDDeviceManager {
private static final String TAG = "hidapi";
private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
private static HIDDeviceManager sManager;
private static int sManagerRefCount = 0;
public static HIDDeviceManager acquire(Context context) {
if (sManagerRefCount == 0) {
sManager = new HIDDeviceManager(context);
}
++sManagerRefCount;
return sManager;
}
public static void release(HIDDeviceManager manager) {
if (manager == sManager) {
--sManagerRefCount;
if (sManagerRefCount == 0) {
sManager.close();
sManager = null;
}
}
}
private Context mContext;
private HashMap<Integer, HIDDevice> mDevicesById = new HashMap<Integer, HIDDevice>();
private HashMap<BluetoothDevice, HIDDeviceBLESteamController> mBluetoothDevices = new HashMap<BluetoothDevice, HIDDeviceBLESteamController>();
private int mNextDeviceId = 0;
private SharedPreferences mSharedPreferences = null;
private boolean mIsChromebook = false;
private UsbManager mUsbManager;
private Handler mHandler;
private BluetoothManager mBluetoothManager;
private List<BluetoothDevice> mLastBluetoothDevices;
private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDeviceAttached(usbDevice);
} else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDeviceDetached(usbDevice);
} else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
}
}
};
private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
// Bluetooth device was connected. If it was a Steam Controller, handle it
if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Log.d(TAG, "Bluetooth device connected: " + device);
if (isSteamController(device)) {
connectBluetoothDevice(device);
}
}
// Bluetooth device was disconnected, remove from controller manager (if any)
if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Log.d(TAG, "Bluetooth device disconnected: " + device);
disconnectBluetoothDevice(device);
}
}
};
private HIDDeviceManager(final Context context) {
mContext = context;
HIDDeviceRegisterCallback();
mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
// if (shouldClear) {
// SharedPreferences.Editor spedit = mSharedPreferences.edit();
// spedit.clear();
// spedit.commit();
// }
// else
{
mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
}
}
public Context getContext() {
return mContext;
}
public int getDeviceIDForIdentifier(String identifier) {
SharedPreferences.Editor spedit = mSharedPreferences.edit();
int result = mSharedPreferences.getInt(identifier, 0);
if (result == 0) {
result = mNextDeviceId++;
spedit.putInt("next_device_id", mNextDeviceId);
}
spedit.putInt(identifier, result);
spedit.commit();
return result;
}
private void initializeUSB() {
mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
if (mUsbManager == null) {
return;
}
/*
// Logging
for (UsbDevice device : mUsbManager.getDeviceList().values()) {
Log.i(TAG,"Path: " + device.getDeviceName());
Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
Log.i(TAG,"Product: " + device.getProductName());
Log.i(TAG,"ID: " + device.getDeviceId());
Log.i(TAG,"Class: " + device.getDeviceClass());
Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
Log.i(TAG,"Vendor ID " + device.getVendorId());
Log.i(TAG,"Product ID: " + device.getProductId());
Log.i(TAG,"Interface count: " + device.getInterfaceCount());
Log.i(TAG,"---------------------------------------");
// Get interface details
for (int index = 0; index < device.getInterfaceCount(); index++) {
UsbInterface mUsbInterface = device.getInterface(index);
Log.i(TAG," ***** *****");
Log.i(TAG," Interface index: " + index);
Log.i(TAG," Interface ID: " + mUsbInterface.getId());
Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
// Get endpoint details
for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
{
UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
Log.i(TAG," ++++ ++++ ++++");
Log.i(TAG," Endpoint index: " + epi);
Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
Log.i(TAG," Direction: " + mEndpoint.getDirection());
Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
Log.i(TAG," Interval: " + mEndpoint.getInterval());
Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
Log.i(TAG," Type: " + mEndpoint.getType());
}
}
}
Log.i(TAG," No more devices connected.");
*/
// Register for USB broadcasts and permission completions
IntentFilter filter = new IntentFilter();
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
mContext.registerReceiver(mUsbBroadcast, filter);
for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
handleUsbDeviceAttached(usbDevice);
}
}
UsbManager getUSBManager() {
return mUsbManager;
}
private void shutdownUSB() {
try {
mContext.unregisterReceiver(mUsbBroadcast);
} catch (Exception e) {
// We may not have registered, that's okay
}
}
private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
return true;
}
if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
return true;
}
return false;
}
private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
final int XB360_IFACE_SUBCLASS = 93;
final int XB360_IFACE_PROTOCOL = 1; // Wired
final int XB360W_IFACE_PROTOCOL = 129; // Wireless
final int[] SUPPORTED_VENDORS = {
0x0079, // GPD Win 2
0x044f, // Thrustmaster
0x045e, // Microsoft
0x046d, // Logitech
0x056e, // Elecom
0x06a3, // Saitek
0x0738, // Mad Catz
0x07ff, // Mad Catz
0x0e6f, // PDP
0x0f0d, // Hori
0x1038, // SteelSeries
0x11c9, // Nacon
0x12ab, // Unknown
0x1430, // RedOctane
0x146b, // BigBen
0x1532, // Razer Sabertooth
0x15e4, // Numark
0x162e, // Joytech
0x1689, // Razer Onza
0x1949, // Lab126, Inc.
0x1bad, // Harmonix
0x20d6, // PowerA
0x24c6, // PowerA
0x2c22, // Qanba
0x2dc8, // 8BitDo
0x9886, // ASTRO Gaming
};
if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
(usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
int vendor_id = usbDevice.getVendorId();
for (int supportedVid : SUPPORTED_VENDORS) {
if (vendor_id == supportedVid) {
return true;
}
}
}
return false;
}
private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
final int XB1_IFACE_SUBCLASS = 71;
final int XB1_IFACE_PROTOCOL = 208;
final int[] SUPPORTED_VENDORS = {
0x03f0, // HP
0x044f, // Thrustmaster
0x045e, // Microsoft
0x0738, // Mad Catz
0x0e6f, // PDP
0x0f0d, // Hori
0x10f5, // Turtle Beach
0x1532, // Razer Wildcat
0x20d6, // PowerA
0x24c6, // PowerA
0x2dc8, // 8BitDo
0x2e24, // Hyperkin
};
if (usbInterface.getId() == 0 &&
usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
int vendor_id = usbDevice.getVendorId();
for (int supportedVid : SUPPORTED_VENDORS) {
if (vendor_id == supportedVid) {
return true;
}
}
}
return false;
}
private void handleUsbDeviceAttached(UsbDevice usbDevice) {
connectHIDDeviceUSB(usbDevice);
}
private void handleUsbDeviceDetached(UsbDevice usbDevice) {
List<Integer> devices = new ArrayList<Integer>();
for (HIDDevice device : mDevicesById.values()) {
if (usbDevice.equals(device.getDevice())) {
devices.add(device.getId());
}
}
for (int id : devices) {
HIDDevice device = mDevicesById.get(id);
mDevicesById.remove(id);
device.shutdown();
HIDDeviceDisconnected(id);
}
}
private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
for (HIDDevice device : mDevicesById.values()) {
if (usbDevice.equals(device.getDevice())) {
boolean opened = false;
if (permission_granted) {
opened = device.open();
}
HIDDeviceOpenResult(device.getId(), opened);
}
}
}
private void connectHIDDeviceUSB(UsbDevice usbDevice) {
synchronized (this) {
int interface_mask = 0;
for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
UsbInterface usbInterface = usbDevice.getInterface(interface_index);
if (isHIDDeviceInterface(usbDevice, usbInterface)) {
// Check to see if we've already added this interface
// This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
int interface_id = usbInterface.getId();
if ((interface_mask & (1 << interface_id)) != 0) {
continue;
}
interface_mask |= (1 << interface_id);
HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
int id = device.getId();
mDevicesById.put(id, device);
HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol());
}
}
}
}
private void initializeBluetooth() {
Log.d(TAG, "Initializing Bluetooth");
if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ &&
mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
return;
}
if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) {
Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
return;
}
// Find bonded bluetooth controllers and create SteamControllers for them
mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
if (mBluetoothManager == null) {
// This device doesn't support Bluetooth.
return;
}
BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
if (btAdapter == null) {
// This device has Bluetooth support in the codebase, but has no available adapters.
return;
}
// Get our bonded devices.
for (BluetoothDevice device : btAdapter.getBondedDevices()) {
Log.d(TAG, "Bluetooth device available: " + device);
if (isSteamController(device)) {
connectBluetoothDevice(device);
}
}
// NOTE: These don't work on Chromebooks, to my undying dismay.
IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
mContext.registerReceiver(mBluetoothBroadcast, filter);
if (mIsChromebook) {
mHandler = new Handler(Looper.getMainLooper());
mLastBluetoothDevices = new ArrayList<BluetoothDevice>();
// final HIDDeviceManager finalThis = this;
// mHandler.postDelayed(new Runnable() {
// @Override
// public void run() {
// finalThis.chromebookConnectionHandler();
// }
// }, 5000);
}
}
private void shutdownBluetooth() {
try {
mContext.unregisterReceiver(mBluetoothBroadcast);
} catch (Exception e) {
// We may not have registered, that's okay
}
}
// Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
// This function provides a sort of dummy version of that, watching for changes in the
// connected devices and attempting to add controllers as things change.
public void chromebookConnectionHandler() {
if (!mIsChromebook) {
return;
}
ArrayList<BluetoothDevice> disconnected = new ArrayList<BluetoothDevice>();
ArrayList<BluetoothDevice> connected = new ArrayList<BluetoothDevice>();
List<BluetoothDevice> currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
for (BluetoothDevice bluetoothDevice : currentConnected) {
if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
connected.add(bluetoothDevice);
}
}
for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
if (!currentConnected.contains(bluetoothDevice)) {
disconnected.add(bluetoothDevice);
}
}
mLastBluetoothDevices = currentConnected;
for (BluetoothDevice bluetoothDevice : disconnected) {
disconnectBluetoothDevice(bluetoothDevice);
}
for (BluetoothDevice bluetoothDevice : connected) {
connectBluetoothDevice(bluetoothDevice);
}
final HIDDeviceManager finalThis = this;
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
finalThis.chromebookConnectionHandler();
}
}, 10000);
}
public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
synchronized (this) {
if (mBluetoothDevices.containsKey(bluetoothDevice)) {
Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
device.reconnect();
return false;
}
HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
int id = device.getId();
mBluetoothDevices.put(bluetoothDevice, device);
mDevicesById.put(id, device);
// The Steam Controller will mark itself connected once initialization is complete
}
return true;
}
public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
synchronized (this) {
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
if (device == null)
return;
int id = device.getId();
mBluetoothDevices.remove(bluetoothDevice);
mDevicesById.remove(id);
device.shutdown();
HIDDeviceDisconnected(id);
}
}
public boolean isSteamController(BluetoothDevice bluetoothDevice) {
// Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
if (bluetoothDevice == null) {
return false;
}
// If the device has no local name, we really don't want to try an equality check against it.
if (bluetoothDevice.getName() == null) {
return false;
}
return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
}
private void close() {
shutdownUSB();
shutdownBluetooth();
synchronized (this) {
for (HIDDevice device : mDevicesById.values()) {
device.shutdown();
}
mDevicesById.clear();
mBluetoothDevices.clear();
HIDDeviceReleaseCallback();
}
}
public void setFrozen(boolean frozen) {
synchronized (this) {
for (HIDDevice device : mDevicesById.values()) {
device.setFrozen(frozen);
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
private HIDDevice getDevice(int id) {
synchronized (this) {
HIDDevice result = mDevicesById.get(id);
if (result == null) {
Log.v(TAG, "No device for id: " + id);
Log.v(TAG, "Available devices: " + mDevicesById.keySet());
}
return result;
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
////////// JNI interface functions
//////////////////////////////////////////////////////////////////////////////////////////////////////
public boolean initialize(boolean usb, boolean bluetooth) {
Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
if (usb) {
initializeUSB();
}
if (bluetooth) {
initializeBluetooth();
}
return true;
}
public boolean openDevice(int deviceID) {
Log.v(TAG, "openDevice deviceID=" + deviceID);
HIDDevice device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return false;
}
// Look to see if this is a USB device and we have permission to access it
UsbDevice usbDevice = device.getDevice();
if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
HIDDeviceOpenPending(deviceID);
try {
final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
int flags;
if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
flags = FLAG_MUTABLE;
} else {
flags = 0;
}
mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags));
} catch (Exception e) {
Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
HIDDeviceOpenResult(deviceID, false);
}
return false;
}
try {
return device.open();
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return false;
}
public int sendOutputReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return -1;
}
return device.sendOutputReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return -1;
}
public int sendFeatureReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return -1;
}
return device.sendFeatureReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return -1;
}
public boolean getFeatureReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "getFeatureReport deviceID=" + deviceID);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return false;
}
return device.getFeatureReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return false;
}
public void closeDevice(int deviceID) {
try {
Log.v(TAG, "closeDevice deviceID=" + deviceID);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return;
}
device.close();
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
/////////////// Native methods
//////////////////////////////////////////////////////////////////////////////////////////////////////
private native void HIDDeviceRegisterCallback();
private native void HIDDeviceReleaseCallback();
native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol);
native void HIDDeviceOpenPending(int deviceID);
native void HIDDeviceOpenResult(int deviceID, boolean opened);
native void HIDDeviceDisconnected(int deviceID);
native void HIDDeviceInputReport(int deviceID, byte[] report);
native void HIDDeviceFeatureReport(int deviceID, byte[] report);
}
@@ -0,0 +1,309 @@
package org.libsdl.app;
import android.hardware.usb.*;
import android.os.Build;
import android.util.Log;
import java.util.Arrays;
class HIDDeviceUSB implements HIDDevice {
private static final String TAG = "hidapi";
protected HIDDeviceManager mManager;
protected UsbDevice mDevice;
protected int mInterfaceIndex;
protected int mInterface;
protected int mDeviceId;
protected UsbDeviceConnection mConnection;
protected UsbEndpoint mInputEndpoint;
protected UsbEndpoint mOutputEndpoint;
protected InputThread mInputThread;
protected boolean mRunning;
protected boolean mFrozen;
public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
mManager = manager;
mDevice = usbDevice;
mInterfaceIndex = interface_index;
mInterface = mDevice.getInterface(mInterfaceIndex).getId();
mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
mRunning = false;
}
public String getIdentifier() {
return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
}
@Override
public int getId() {
return mDeviceId;
}
@Override
public int getVendorId() {
return mDevice.getVendorId();
}
@Override
public int getProductId() {
return mDevice.getProductId();
}
@Override
public String getSerialNumber() {
String result = null;
if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
try {
result = mDevice.getSerialNumber();
}
catch (SecurityException exception) {
//Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
}
}
if (result == null) {
result = "";
}
return result;
}
@Override
public int getVersion() {
return 0;
}
@Override
public String getManufacturerName() {
String result = null;
if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
result = mDevice.getManufacturerName();
}
if (result == null) {
result = String.format("%x", getVendorId());
}
return result;
}
@Override
public String getProductName() {
String result = null;
if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
result = mDevice.getProductName();
}
if (result == null) {
result = String.format("%x", getProductId());
}
return result;
}
@Override
public UsbDevice getDevice() {
return mDevice;
}
public String getDeviceName() {
return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
}
@Override
public boolean open() {
mConnection = mManager.getUSBManager().openDevice(mDevice);
if (mConnection == null) {
Log.w(TAG, "Unable to open USB device " + getDeviceName());
return false;
}
// Force claim our interface
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
if (!mConnection.claimInterface(iface, true)) {
Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
close();
return false;
}
// Find the endpoints
for (int j = 0; j < iface.getEndpointCount(); j++) {
UsbEndpoint endpt = iface.getEndpoint(j);
switch (endpt.getDirection()) {
case UsbConstants.USB_DIR_IN:
if (mInputEndpoint == null) {
mInputEndpoint = endpt;
}
break;
case UsbConstants.USB_DIR_OUT:
if (mOutputEndpoint == null) {
mOutputEndpoint = endpt;
}
break;
}
}
// Make sure the required endpoints were present
if (mInputEndpoint == null || mOutputEndpoint == null) {
Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
close();
return false;
}
// Start listening for input
mRunning = true;
mInputThread = new InputThread();
mInputThread.start();
return true;
}
@Override
public int sendFeatureReport(byte[] report) {
int res = -1;
int offset = 0;
int length = report.length;
boolean skipped_report_id = false;
byte report_number = report[0];
if (report_number == 0x0) {
++offset;
--length;
skipped_report_id = true;
}
res = mConnection.controlTransfer(
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
0x09/*HID set_report*/,
(3/*HID feature*/ << 8) | report_number,
mInterface,
report, offset, length,
1000/*timeout millis*/);
if (res < 0) {
Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName());
return -1;
}
if (skipped_report_id) {
++length;
}
return length;
}
@Override
public int sendOutputReport(byte[] report) {
int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
if (r != report.length) {
Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName());
}
return r;
}
@Override
public boolean getFeatureReport(byte[] report) {
int res = -1;
int offset = 0;
int length = report.length;
boolean skipped_report_id = false;
byte report_number = report[0];
if (report_number == 0x0) {
/* Offset the return buffer by 1, so that the report ID
will remain in byte 0. */
++offset;
--length;
skipped_report_id = true;
}
res = mConnection.controlTransfer(
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
0x01/*HID get_report*/,
(3/*HID feature*/ << 8) | report_number,
mInterface,
report, offset, length,
1000/*timeout millis*/);
if (res < 0) {
Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
return false;
}
if (skipped_report_id) {
++res;
++length;
}
byte[] data;
if (res == length) {
data = report;
} else {
data = Arrays.copyOfRange(report, 0, res);
}
mManager.HIDDeviceFeatureReport(mDeviceId, data);
return true;
}
@Override
public void close() {
mRunning = false;
if (mInputThread != null) {
while (mInputThread.isAlive()) {
mInputThread.interrupt();
try {
mInputThread.join();
} catch (InterruptedException e) {
// Keep trying until we're done
}
}
mInputThread = null;
}
if (mConnection != null) {
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
mConnection.releaseInterface(iface);
mConnection.close();
mConnection = null;
}
}
@Override
public void shutdown() {
close();
mManager = null;
}
@Override
public void setFrozen(boolean frozen) {
mFrozen = frozen;
}
protected class InputThread extends Thread {
@Override
public void run() {
int packetSize = mInputEndpoint.getMaxPacketSize();
byte[] packet = new byte[packetSize];
while (mRunning) {
int r;
try
{
r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
}
catch (Exception e)
{
Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
break;
}
if (r < 0) {
// Could be a timeout or an I/O error
}
if (r > 0) {
byte[] data;
if (r == packetSize) {
data = packet;
} else {
data = Arrays.copyOfRange(packet, 0, r);
}
if (!mFrozen) {
mManager.HIDDeviceInputReport(mDeviceId, data);
}
}
}
}
}
}
@@ -0,0 +1,86 @@
package org.libsdl.app;
import android.content.Context;
import java.lang.Class;
import java.lang.reflect.Method;
/**
SDL library initialization
*/
public class SDL {
// This function should be called first and sets up the native code
// so it can call into the Java classes
public static void setupJNI() {
SDLActivity.nativeSetupJNI();
SDLAudioManager.nativeSetupJNI();
SDLControllerManager.nativeSetupJNI();
}
// This function should be called each time the activity is started
public static void initialize() {
setContext(null);
SDLActivity.initialize();
SDLAudioManager.initialize();
SDLControllerManager.initialize();
}
// This function stores the current activity (SDL or not)
public static void setContext(Context context) {
SDLAudioManager.setContext(context);
mContext = context;
}
public static Context getContext() {
return mContext;
}
public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
if (libraryName == null) {
throw new NullPointerException("No library name provided.");
}
try {
// Let's see if we have ReLinker available in the project. This is necessary for
// some projects that have huge numbers of local libraries bundled, and thus may
// trip a bug in Android's native library loader which ReLinker works around. (If
// loadLibrary works properly, ReLinker will simply use the normal Android method
// internally.)
//
// To use ReLinker, just add it as a dependency. For more information, see
// https://github.com/KeepSafe/ReLinker for ReLinker's repository.
//
Class<?> relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
Class<?> relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
Class<?> contextClass = mContext.getClassLoader().loadClass("android.content.Context");
Class<?> stringClass = mContext.getClassLoader().loadClass("java.lang.String");
// Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
// they've changed during updates.
Method forceMethod = relinkClass.getDeclaredMethod("force");
Object relinkInstance = forceMethod.invoke(null);
Class<?> relinkInstanceClass = relinkInstance.getClass();
// Actually load the library!
Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
loadMethod.invoke(relinkInstance, mContext, libraryName, null, null);
}
catch (final Throwable e) {
// Fall back
try {
System.loadLibrary(libraryName);
}
catch (final UnsatisfiedLinkError ule) {
throw ule;
}
catch (final SecurityException se) {
throw se;
}
}
}
protected static Context mContext;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
package org.libsdl.app;
import android.content.Context;
import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.util.Log;
import java.util.Arrays;
public class SDLAudioManager {
protected static final String TAG = "SDLAudio";
protected static AudioTrack mAudioTrack;
protected static AudioRecord mAudioRecord;
protected static Context mContext;
private static final int[] NO_DEVICES = {};
private static AudioDeviceCallback mAudioDeviceCallback;
public static void initialize() {
mAudioTrack = null;
mAudioRecord = null;
mAudioDeviceCallback = null;
if(Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */)
{
mAudioDeviceCallback = new AudioDeviceCallback() {
@Override
public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
Arrays.stream(addedDevices).forEach(deviceInfo -> addAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
}
@Override
public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
Arrays.stream(removedDevices).forEach(deviceInfo -> removeAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
}
};
}
}
public static void setContext(Context context) {
mContext = context;
if (context != null) {
registerAudioDeviceCallback();
}
}
public static void release(Context context) {
unregisterAudioDeviceCallback(context);
}
// Audio
protected static String getAudioFormatString(int audioFormat) {
switch (audioFormat) {
case AudioFormat.ENCODING_PCM_8BIT:
return "8-bit";
case AudioFormat.ENCODING_PCM_16BIT:
return "16-bit";
case AudioFormat.ENCODING_PCM_FLOAT:
return "float";
default:
return Integer.toString(audioFormat);
}
}
protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
int channelConfig;
int sampleSize;
int frameSize;
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz");
/* On older devices let's use known good settings */
if (Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
if (desiredChannels > 2) {
desiredChannels = 2;
}
}
/* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */
if (Build.VERSION.SDK_INT < 22 /* Android 5.1 (LOLLIPOP_MR1) */) {
if (sampleRate < 8000) {
sampleRate = 8000;
} else if (sampleRate > 48000) {
sampleRate = 48000;
}
}
if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) {
int minSDKVersion = (isCapture ? 23 /* Android 6.0 (M) */ : 21 /* Android 5.0 (LOLLIPOP) */);
if (Build.VERSION.SDK_INT < minSDKVersion) {
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
}
}
switch (audioFormat)
{
case AudioFormat.ENCODING_PCM_8BIT:
sampleSize = 1;
break;
case AudioFormat.ENCODING_PCM_16BIT:
sampleSize = 2;
break;
case AudioFormat.ENCODING_PCM_FLOAT:
sampleSize = 4;
break;
default:
Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT");
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
sampleSize = 2;
break;
}
if (isCapture) {
switch (desiredChannels) {
case 1:
channelConfig = AudioFormat.CHANNEL_IN_MONO;
break;
case 2:
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
break;
default:
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
desiredChannels = 2;
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
break;
}
} else {
switch (desiredChannels) {
case 1:
channelConfig = AudioFormat.CHANNEL_OUT_MONO;
break;
case 2:
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
break;
case 3:
channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
break;
case 4:
channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
break;
case 5:
channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
break;
case 6:
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
break;
case 7:
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER;
break;
case 8:
if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
} else {
Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround");
desiredChannels = 6;
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
}
break;
default:
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
desiredChannels = 2;
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
break;
}
/*
Log.v(TAG, "Speaker configuration (and order of channels):");
if ((channelConfig & 0x00000004) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT");
}
if ((channelConfig & 0x00000008) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT");
}
if ((channelConfig & 0x00000010) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER");
}
if ((channelConfig & 0x00000020) != 0) {
Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY");
}
if ((channelConfig & 0x00000040) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_LEFT");
}
if ((channelConfig & 0x00000080) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT");
}
if ((channelConfig & 0x00000100) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER");
}
if ((channelConfig & 0x00000200) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER");
}
if ((channelConfig & 0x00000400) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_CENTER");
}
if ((channelConfig & 0x00000800) != 0) {
Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT");
}
if ((channelConfig & 0x00001000) != 0) {
Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT");
}
*/
}
frameSize = (sampleSize * desiredChannels);
// Let the user pick a larger buffer if they really want -- but ye
// gods they probably shouldn't, the minimums are horrifyingly high
// latency already
int minBufferSize;
if (isCapture) {
minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
} else {
minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
}
desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize);
int[] results = new int[4];
if (isCapture) {
if (mAudioRecord == null) {
mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate,
channelConfig, audioFormat, desiredFrames * frameSize);
// see notes about AudioTrack state in audioOpen(), above. Probably also applies here.
if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "Failed during initialization of AudioRecord");
mAudioRecord.release();
mAudioRecord = null;
return null;
}
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
mAudioRecord.setPreferredDevice(getOutputAudioDeviceInfo(deviceId));
}
mAudioRecord.startRecording();
}
results[0] = mAudioRecord.getSampleRate();
results[1] = mAudioRecord.getAudioFormat();
results[2] = mAudioRecord.getChannelCount();
} else {
if (mAudioTrack == null) {
mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM);
// Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid
// Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java
// Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState()
if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
/* Try again, with safer values */
Log.e(TAG, "Failed during initialization of Audio Track");
mAudioTrack.release();
mAudioTrack = null;
return null;
}
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
mAudioTrack.setPreferredDevice(getInputAudioDeviceInfo(deviceId));
}
mAudioTrack.play();
}
results[0] = mAudioTrack.getSampleRate();
results[1] = mAudioTrack.getAudioFormat();
results[2] = mAudioTrack.getChannelCount();
}
results[3] = desiredFrames;
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz");
return results;
}
private static AudioDeviceInfo getInputAudioDeviceInfo(int deviceId) {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS))
.filter(deviceInfo -> deviceInfo.getId() == deviceId)
.findFirst()
.orElse(null);
} else {
return null;
}
}
private static AudioDeviceInfo getOutputAudioDeviceInfo(int deviceId) {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS))
.filter(deviceInfo -> deviceInfo.getId() == deviceId)
.findFirst()
.orElse(null);
} else {
return null;
}
}
private static void registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
audioManager.registerAudioDeviceCallback(mAudioDeviceCallback, null);
}
}
private static void unregisterAudioDeviceCallback(Context context) {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
audioManager.unregisterAudioDeviceCallback(mAudioDeviceCallback);
}
}
/**
* This method is called by SDL using JNI.
*/
public static int[] getAudioOutputDevices() {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
} else {
return NO_DEVICES;
}
}
/**
* This method is called by SDL using JNI.
*/
public static int[] getAudioInputDevices() {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
} else {
return NO_DEVICES;
}
}
/**
* This method is called by SDL using JNI.
*/
public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteFloatBuffer(float[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
if (android.os.Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
Log.e(TAG, "Attempted to make an incompatible audio call with uninitialized audio! (floating-point output is supported since Android 5.0 Lollipop)");
return;
}
for (int i = 0; i < buffer.length;) {
int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(float)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteShortBuffer(short[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
for (int i = 0; i < buffer.length;) {
int result = mAudioTrack.write(buffer, i, buffer.length - i);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(short)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteByteBuffer(byte[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
for (int i = 0; i < buffer.length; ) {
int result = mAudioTrack.write(buffer, i, buffer.length - i);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(byte)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
}
/** This method is called by SDL using JNI. */
public static int captureReadFloatBuffer(float[] buffer, boolean blocking) {
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
return 0;
} else {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
}
/** This method is called by SDL using JNI. */
public static int captureReadShortBuffer(short[] buffer, boolean blocking) {
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
return mAudioRecord.read(buffer, 0, buffer.length);
} else {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
}
/** This method is called by SDL using JNI. */
public static int captureReadByteBuffer(byte[] buffer, boolean blocking) {
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
return mAudioRecord.read(buffer, 0, buffer.length);
} else {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
}
/** This method is called by SDL using JNI. */
public static void audioClose() {
if (mAudioTrack != null) {
mAudioTrack.stop();
mAudioTrack.release();
mAudioTrack = null;
}
}
/** This method is called by SDL using JNI. */
public static void captureClose() {
if (mAudioRecord != null) {
mAudioRecord.stop();
mAudioRecord.release();
mAudioRecord = null;
}
}
/** This method is called by SDL using JNI. */
public static void audioSetThreadPriority(boolean iscapture, int device_id) {
try {
/* Set thread name */
if (iscapture) {
Thread.currentThread().setName("SDLAudioC" + device_id);
} else {
Thread.currentThread().setName("SDLAudioP" + device_id);
}
/* Set thread priority */
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
} catch (Exception e) {
Log.v(TAG, "modify thread properties failed " + e.toString());
}
}
public static native int nativeSetupJNI();
public static native void removeAudioDevice(boolean isCapture, int deviceId);
public static native void addAudioDevice(boolean isCapture, int deviceId);
}
@@ -0,0 +1,854 @@
package org.libsdl.app;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import android.content.Context;
import android.os.Build;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.util.Log;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
public class SDLControllerManager
{
public static native int nativeSetupJNI();
public static native int nativeAddJoystick(int device_id, String name, String desc,
int vendor_id, int product_id,
boolean is_accelerometer, int button_mask,
int naxes, int axis_mask, int nhats, int nballs);
public static native int nativeRemoveJoystick(int device_id);
public static native int nativeAddHaptic(int device_id, String name);
public static native int nativeRemoveHaptic(int device_id);
public static native int onNativePadDown(int device_id, int keycode);
public static native int onNativePadUp(int device_id, int keycode);
public static native void onNativeJoy(int device_id, int axis,
float value);
public static native void onNativeHat(int device_id, int hat_id,
int x, int y);
protected static SDLJoystickHandler mJoystickHandler;
protected static SDLHapticHandler mHapticHandler;
private static final String TAG = "SDLControllerManager";
public static void initialize() {
if (mJoystickHandler == null) {
if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
mJoystickHandler = new SDLJoystickHandler_API19();
} else {
mJoystickHandler = new SDLJoystickHandler_API16();
}
}
if (mHapticHandler == null) {
if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
mHapticHandler = new SDLHapticHandler_API26();
} else {
mHapticHandler = new SDLHapticHandler();
}
}
}
// Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
public static boolean handleJoystickMotionEvent(MotionEvent event) {
return mJoystickHandler.handleMotionEvent(event);
}
/**
* This method is called by SDL using JNI.
*/
public static void pollInputDevices() {
mJoystickHandler.pollInputDevices();
}
/**
* This method is called by SDL using JNI.
*/
public static void pollHapticDevices() {
mHapticHandler.pollHapticDevices();
}
/**
* This method is called by SDL using JNI.
*/
public static void hapticRun(int device_id, float intensity, int length) {
mHapticHandler.run(device_id, intensity, length);
}
/**
* This method is called by SDL using JNI.
*/
public static void hapticStop(int device_id)
{
mHapticHandler.stop(device_id);
}
// Check if a given device is considered a possible SDL joystick
public static boolean isDeviceSDLJoystick(int deviceId) {
InputDevice device = InputDevice.getDevice(deviceId);
// We cannot use InputDevice.isVirtual before API 16, so let's accept
// only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
if ((device == null) || (deviceId < 0)) {
return false;
}
int sources = device.getSources();
/* This is called for every button press, so let's not spam the logs */
/*
if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
}
if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
}
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
}
*/
return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
);
}
}
class SDLJoystickHandler {
/**
* Handles given MotionEvent.
* @param event the event to be handled.
* @return if given event was processed.
*/
public boolean handleMotionEvent(MotionEvent event) {
return false;
}
/**
* Handles adding and removing of input devices.
*/
public void pollInputDevices() {
}
}
/* Actual joystick functionality available for API >= 12 devices */
class SDLJoystickHandler_API16 extends SDLJoystickHandler {
static class SDLJoystick {
public int device_id;
public String name;
public String desc;
public ArrayList<InputDevice.MotionRange> axes;
public ArrayList<InputDevice.MotionRange> hats;
}
static class RangeComparator implements Comparator<InputDevice.MotionRange> {
@Override
public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
// Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
int arg0Axis = arg0.getAxis();
int arg1Axis = arg1.getAxis();
if (arg0Axis == MotionEvent.AXIS_GAS) {
arg0Axis = MotionEvent.AXIS_BRAKE;
} else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
arg0Axis = MotionEvent.AXIS_GAS;
}
if (arg1Axis == MotionEvent.AXIS_GAS) {
arg1Axis = MotionEvent.AXIS_BRAKE;
} else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
arg1Axis = MotionEvent.AXIS_GAS;
}
// Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ.
// This is because the usual pairing are:
// - AXIS_X + AXIS_Y (left stick).
// - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers).
// - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers).
// This sorts the axes in the above order, which tends to be correct
// for Xbox-ish game pads that have the right stick on RX/RY and the
// triggers on Z/RZ.
//
// Gamepads that don't have AXIS_Z/AXIS_RZ but use
// AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this.
//
// References:
// - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
// - https://www.kernel.org/doc/html/latest/input/gamepad.html
if (arg0Axis == MotionEvent.AXIS_Z) {
arg0Axis = MotionEvent.AXIS_RZ - 1;
} else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) {
--arg0Axis;
}
if (arg1Axis == MotionEvent.AXIS_Z) {
arg1Axis = MotionEvent.AXIS_RZ - 1;
} else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) {
--arg1Axis;
}
return arg0Axis - arg1Axis;
}
}
private final ArrayList<SDLJoystick> mJoysticks;
public SDLJoystickHandler_API16() {
mJoysticks = new ArrayList<SDLJoystick>();
}
@Override
public void pollInputDevices() {
int[] deviceIds = InputDevice.getDeviceIds();
for (int device_id : deviceIds) {
if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
SDLJoystick joystick = getJoystick(device_id);
if (joystick == null) {
InputDevice joystickDevice = InputDevice.getDevice(device_id);
joystick = new SDLJoystick();
joystick.device_id = device_id;
joystick.name = joystickDevice.getName();
joystick.desc = getJoystickDescriptor(joystickDevice);
joystick.axes = new ArrayList<InputDevice.MotionRange>();
joystick.hats = new ArrayList<InputDevice.MotionRange>();
List<InputDevice.MotionRange> ranges = joystickDevice.getMotionRanges();
Collections.sort(ranges, new RangeComparator());
for (InputDevice.MotionRange range : ranges) {
if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
joystick.hats.add(range);
} else {
joystick.axes.add(range);
}
}
}
mJoysticks.add(joystick);
SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
getVendorId(joystickDevice), getProductId(joystickDevice), false,
getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, 0);
}
}
}
/* Check removed devices */
ArrayList<Integer> removedDevices = null;
for (SDLJoystick joystick : mJoysticks) {
int device_id = joystick.device_id;
int i;
for (i = 0; i < deviceIds.length; i++) {
if (device_id == deviceIds[i]) break;
}
if (i == deviceIds.length) {
if (removedDevices == null) {
removedDevices = new ArrayList<Integer>();
}
removedDevices.add(device_id);
}
}
if (removedDevices != null) {
for (int device_id : removedDevices) {
SDLControllerManager.nativeRemoveJoystick(device_id);
for (int i = 0; i < mJoysticks.size(); i++) {
if (mJoysticks.get(i).device_id == device_id) {
mJoysticks.remove(i);
break;
}
}
}
}
}
protected SDLJoystick getJoystick(int device_id) {
for (SDLJoystick joystick : mJoysticks) {
if (joystick.device_id == device_id) {
return joystick;
}
}
return null;
}
@Override
public boolean handleMotionEvent(MotionEvent event) {
int actionPointerIndex = event.getActionIndex();
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_MOVE) {
SDLJoystick joystick = getJoystick(event.getDeviceId());
if (joystick != null) {
for (int i = 0; i < joystick.axes.size(); i++) {
InputDevice.MotionRange range = joystick.axes.get(i);
/* Normalize the value to -1...1 */
float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
}
for (int i = 0; i < joystick.hats.size() / 2; i++) {
int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
}
}
}
return true;
}
public String getJoystickDescriptor(InputDevice joystickDevice) {
String desc = joystickDevice.getDescriptor();
if (desc != null && !desc.isEmpty()) {
return desc;
}
return joystickDevice.getName();
}
public int getProductId(InputDevice joystickDevice) {
return 0;
}
public int getVendorId(InputDevice joystickDevice) {
return 0;
}
public int getAxisMask(List<InputDevice.MotionRange> ranges) {
return -1;
}
public int getButtonMask(InputDevice joystickDevice) {
return -1;
}
}
class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
@Override
public int getProductId(InputDevice joystickDevice) {
return joystickDevice.getProductId();
}
@Override
public int getVendorId(InputDevice joystickDevice) {
return joystickDevice.getVendorId();
}
@Override
public int getAxisMask(List<InputDevice.MotionRange> ranges) {
// For compatibility, keep computing the axis mask like before,
// only really distinguishing 2, 4 and 6 axes.
int axis_mask = 0;
if (ranges.size() >= 2) {
// ((1 << SDL_GAMEPAD_AXIS_LEFTX) | (1 << SDL_GAMEPAD_AXIS_LEFTY))
axis_mask |= 0x0003;
}
if (ranges.size() >= 4) {
// ((1 << SDL_GAMEPAD_AXIS_RIGHTX) | (1 << SDL_GAMEPAD_AXIS_RIGHTY))
axis_mask |= 0x000c;
}
if (ranges.size() >= 6) {
// ((1 << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1 << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER))
axis_mask |= 0x0030;
}
// Also add an indicator bit for whether the sorting order has changed.
// This serves to disable outdated gamecontrollerdb.txt mappings.
boolean have_z = false;
boolean have_past_z_before_rz = false;
for (InputDevice.MotionRange range : ranges) {
int axis = range.getAxis();
if (axis == MotionEvent.AXIS_Z) {
have_z = true;
} else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) {
have_past_z_before_rz = true;
}
}
if (have_z && have_past_z_before_rz) {
// If both these exist, the compare() function changed sorting order.
// Set a bit to indicate this fact.
axis_mask |= 0x8000;
}
return axis_mask;
}
@Override
public int getButtonMask(InputDevice joystickDevice) {
int button_mask = 0;
int[] keys = new int[] {
KeyEvent.KEYCODE_BUTTON_A,
KeyEvent.KEYCODE_BUTTON_B,
KeyEvent.KEYCODE_BUTTON_X,
KeyEvent.KEYCODE_BUTTON_Y,
KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_MENU,
KeyEvent.KEYCODE_BUTTON_MODE,
KeyEvent.KEYCODE_BUTTON_START,
KeyEvent.KEYCODE_BUTTON_THUMBL,
KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_L1,
KeyEvent.KEYCODE_BUTTON_R1,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_BUTTON_SELECT,
KeyEvent.KEYCODE_DPAD_CENTER,
// These don't map into any SDL controller buttons directly
KeyEvent.KEYCODE_BUTTON_L2,
KeyEvent.KEYCODE_BUTTON_R2,
KeyEvent.KEYCODE_BUTTON_C,
KeyEvent.KEYCODE_BUTTON_Z,
KeyEvent.KEYCODE_BUTTON_1,
KeyEvent.KEYCODE_BUTTON_2,
KeyEvent.KEYCODE_BUTTON_3,
KeyEvent.KEYCODE_BUTTON_4,
KeyEvent.KEYCODE_BUTTON_5,
KeyEvent.KEYCODE_BUTTON_6,
KeyEvent.KEYCODE_BUTTON_7,
KeyEvent.KEYCODE_BUTTON_8,
KeyEvent.KEYCODE_BUTTON_9,
KeyEvent.KEYCODE_BUTTON_10,
KeyEvent.KEYCODE_BUTTON_11,
KeyEvent.KEYCODE_BUTTON_12,
KeyEvent.KEYCODE_BUTTON_13,
KeyEvent.KEYCODE_BUTTON_14,
KeyEvent.KEYCODE_BUTTON_15,
KeyEvent.KEYCODE_BUTTON_16,
};
int[] masks = new int[] {
(1 << 0), // A -> A
(1 << 1), // B -> B
(1 << 2), // X -> X
(1 << 3), // Y -> Y
(1 << 4), // BACK -> BACK
(1 << 6), // MENU -> START
(1 << 5), // MODE -> GUIDE
(1 << 6), // START -> START
(1 << 7), // THUMBL -> LEFTSTICK
(1 << 8), // THUMBR -> RIGHTSTICK
(1 << 9), // L1 -> LEFTSHOULDER
(1 << 10), // R1 -> RIGHTSHOULDER
(1 << 11), // DPAD_UP -> DPAD_UP
(1 << 12), // DPAD_DOWN -> DPAD_DOWN
(1 << 13), // DPAD_LEFT -> DPAD_LEFT
(1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
(1 << 4), // SELECT -> BACK
(1 << 0), // DPAD_CENTER -> A
(1 << 15), // L2 -> ??
(1 << 16), // R2 -> ??
(1 << 17), // C -> ??
(1 << 18), // Z -> ??
(1 << 20), // 1 -> ??
(1 << 21), // 2 -> ??
(1 << 22), // 3 -> ??
(1 << 23), // 4 -> ??
(1 << 24), // 5 -> ??
(1 << 25), // 6 -> ??
(1 << 26), // 7 -> ??
(1 << 27), // 8 -> ??
(1 << 28), // 9 -> ??
(1 << 29), // 10 -> ??
(1 << 30), // 11 -> ??
(1 << 31), // 12 -> ??
// We're out of room...
0xFFFFFFFF, // 13 -> ??
0xFFFFFFFF, // 14 -> ??
0xFFFFFFFF, // 15 -> ??
0xFFFFFFFF, // 16 -> ??
};
boolean[] has_keys = joystickDevice.hasKeys(keys);
for (int i = 0; i < keys.length; ++i) {
if (has_keys[i]) {
button_mask |= masks[i];
}
}
return button_mask;
}
}
class SDLHapticHandler_API26 extends SDLHapticHandler {
@Override
public void run(int device_id, float intensity, int length) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length);
if (intensity == 0.0f) {
stop(device_id);
return;
}
int vibeValue = Math.round(intensity * 255);
if (vibeValue > 255) {
vibeValue = 255;
}
if (vibeValue < 1) {
stop(device_id);
return;
}
try {
haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
}
catch (Exception e) {
// Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
// something went horribly wrong with the Android 8.0 APIs.
haptic.vib.vibrate(length);
}
}
}
}
class SDLHapticHandler {
static class SDLHaptic {
public int device_id;
public String name;
public Vibrator vib;
}
private final ArrayList<SDLHaptic> mHaptics;
public SDLHapticHandler() {
mHaptics = new ArrayList<SDLHaptic>();
}
public void run(int device_id, float intensity, int length) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
haptic.vib.vibrate(length);
}
}
public void stop(int device_id) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
haptic.vib.cancel();
}
}
public void pollHapticDevices() {
final int deviceId_VIBRATOR_SERVICE = 999999;
boolean hasVibratorService = false;
int[] deviceIds = InputDevice.getDeviceIds();
// It helps processing the device ids in reverse order
// For example, in the case of the XBox 360 wireless dongle,
// so the first controller seen by SDL matches what the receiver
// considers to be the first controller
for (int i = deviceIds.length - 1; i > -1; i--) {
SDLHaptic haptic = getHaptic(deviceIds[i]);
if (haptic == null) {
InputDevice device = InputDevice.getDevice(deviceIds[i]);
Vibrator vib = device.getVibrator();
if (vib.hasVibrator()) {
haptic = new SDLHaptic();
haptic.device_id = deviceIds[i];
haptic.name = device.getName();
haptic.vib = vib;
mHaptics.add(haptic);
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
}
}
}
/* Check VIBRATOR_SERVICE */
Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
if (vib != null) {
hasVibratorService = vib.hasVibrator();
if (hasVibratorService) {
SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
if (haptic == null) {
haptic = new SDLHaptic();
haptic.device_id = deviceId_VIBRATOR_SERVICE;
haptic.name = "VIBRATOR_SERVICE";
haptic.vib = vib;
mHaptics.add(haptic);
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
}
}
}
/* Check removed devices */
ArrayList<Integer> removedDevices = null;
for (SDLHaptic haptic : mHaptics) {
int device_id = haptic.device_id;
int i;
for (i = 0; i < deviceIds.length; i++) {
if (device_id == deviceIds[i]) break;
}
if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
if (i == deviceIds.length) {
if (removedDevices == null) {
removedDevices = new ArrayList<Integer>();
}
removedDevices.add(device_id);
}
} // else: don't remove the vibrator if it is still present
}
if (removedDevices != null) {
for (int device_id : removedDevices) {
SDLControllerManager.nativeRemoveHaptic(device_id);
for (int i = 0; i < mHaptics.size(); i++) {
if (mHaptics.get(i).device_id == device_id) {
mHaptics.remove(i);
break;
}
}
}
}
}
protected SDLHaptic getHaptic(int device_id) {
for (SDLHaptic haptic : mHaptics) {
if (haptic.device_id == device_id) {
return haptic;
}
}
return null;
}
}
class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener {
// Generic Motion (mouse hover, joystick...) events go here
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
float x, y;
int action;
switch ( event.getSource() ) {
case InputDevice.SOURCE_JOYSTICK:
return SDLControllerManager.handleJoystickMotionEvent(event);
case InputDevice.SOURCE_MOUSE:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
default:
break;
}
break;
default:
break;
}
// Event was not managed
return false;
}
public boolean supportsRelativeMouse() {
return false;
}
public boolean inRelativeMode() {
return false;
}
public boolean setRelativeMouseEnabled(boolean enabled) {
return false;
}
public void reclaimRelativeMouseModeIfNeeded()
{
}
public float getEventX(MotionEvent event) {
return event.getX(0);
}
public float getEventY(MotionEvent event) {
return event.getY(0);
}
}
class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 {
// Generic Motion (mouse hover, joystick...) events go here
private boolean mRelativeModeEnabled;
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
// Handle relative mouse mode
if (mRelativeModeEnabled) {
if (event.getSource() == InputDevice.SOURCE_MOUSE) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_HOVER_MOVE) {
float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
}
}
}
// Event was not managed, call SDLGenericMotionListener_API12 method
return super.onGenericMotion(v, event);
}
@Override
public boolean supportsRelativeMouse() {
return true;
}
@Override
public boolean inRelativeMode() {
return mRelativeModeEnabled;
}
@Override
public boolean setRelativeMouseEnabled(boolean enabled) {
mRelativeModeEnabled = enabled;
return true;
}
@Override
public float getEventX(MotionEvent event) {
if (mRelativeModeEnabled) {
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
} else {
return event.getX(0);
}
}
@Override
public float getEventY(MotionEvent event) {
if (mRelativeModeEnabled) {
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
} else {
return event.getY(0);
}
}
}
class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
// Generic Motion (mouse hover, joystick...) events go here
private boolean mRelativeModeEnabled;
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
float x, y;
int action;
switch ( event.getSource() ) {
case InputDevice.SOURCE_JOYSTICK:
return SDLControllerManager.handleJoystickMotionEvent(event);
case InputDevice.SOURCE_MOUSE:
// DeX desktop mouse cursor is a separate non-standard input type.
case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
default:
break;
}
break;
case InputDevice.SOURCE_MOUSE_RELATIVE:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
default:
break;
}
break;
default:
break;
}
// Event was not managed
return false;
}
@Override
public boolean supportsRelativeMouse() {
return (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */);
}
@Override
public boolean inRelativeMode() {
return mRelativeModeEnabled;
}
@Override
public boolean setRelativeMouseEnabled(boolean enabled) {
if (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */) {
if (enabled) {
SDLActivity.getContentView().requestPointerCapture();
} else {
SDLActivity.getContentView().releasePointerCapture();
}
mRelativeModeEnabled = enabled;
return true;
} else {
return false;
}
}
@Override
public void reclaimRelativeMouseModeIfNeeded()
{
if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
SDLActivity.getContentView().requestPointerCapture();
}
}
@Override
public float getEventX(MotionEvent event) {
// Relative mouse in capture mode will only have relative for X/Y
return event.getX(0);
}
@Override
public float getEventY(MotionEvent event) {
// Relative mouse in capture mode will only have relative for X/Y
return event.getY(0);
}
}
@@ -0,0 +1,405 @@
package org.libsdl.app;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Build;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.WindowManager;
/**
SDLSurface. This is what we draw on, so we need to know when it's created
in order to do anything useful.
Because of this, that's where we set up the SDL thread
*/
public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
View.OnKeyListener, View.OnTouchListener, SensorEventListener {
// Sensors
protected SensorManager mSensorManager;
protected Display mDisplay;
// Keep track of the surface size to normalize touch events
protected float mWidth, mHeight;
// Is SurfaceView ready for rendering
public boolean mIsSurfaceReady;
// Startup
public SDLSurface(Context context) {
super(context);
getHolder().addCallback(this);
setFocusable(true);
setFocusableInTouchMode(true);
requestFocus();
setOnKeyListener(this);
setOnTouchListener(this);
mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
setOnGenericMotionListener(SDLActivity.getMotionListener());
// Some arbitrary defaults to avoid a potential division by zero
mWidth = 1.0f;
mHeight = 1.0f;
mIsSurfaceReady = false;
}
public void handlePause() {
enableSensor(Sensor.TYPE_ACCELEROMETER, false);
}
public void handleResume() {
setFocusable(true);
setFocusableInTouchMode(true);
requestFocus();
setOnKeyListener(this);
setOnTouchListener(this);
enableSensor(Sensor.TYPE_ACCELEROMETER, true);
}
public Surface getNativeSurface() {
return getHolder().getSurface();
}
// Called when we have a valid drawing surface
@Override
public void surfaceCreated(SurfaceHolder holder) {
Log.v("SDL", "surfaceCreated()");
SDLActivity.onNativeSurfaceCreated();
}
// Called when we lose the surface
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
Log.v("SDL", "surfaceDestroyed()");
// Transition to pause, if needed
SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
SDLActivity.handleNativeState();
mIsSurfaceReady = false;
SDLActivity.onNativeSurfaceDestroyed();
}
// Called when the surface is resized
@Override
public void surfaceChanged(SurfaceHolder holder,
int format, int width, int height) {
Log.v("SDL", "surfaceChanged()");
if (SDLActivity.mSingleton == null) {
return;
}
mWidth = width;
mHeight = height;
int nDeviceWidth = width;
int nDeviceHeight = height;
try
{
if (Build.VERSION.SDK_INT >= 17 /* Android 4.2 (JELLY_BEAN_MR1) */) {
DisplayMetrics realMetrics = new DisplayMetrics();
mDisplay.getRealMetrics( realMetrics );
nDeviceWidth = realMetrics.widthPixels;
nDeviceHeight = realMetrics.heightPixels;
}
} catch(Exception ignored) {
}
synchronized(SDLActivity.getContext()) {
// In case we're waiting on a size change after going fullscreen, send a notification.
SDLActivity.getContext().notifyAll();
}
Log.v("SDL", "Window size: " + width + "x" + height);
Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, mDisplay.getRefreshRate());
SDLActivity.onNativeResize();
// Prevent a screen distortion glitch,
// for instance when the device is in Landscape and a Portrait App is resumed.
boolean skip = false;
int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
if (mWidth > mHeight) {
skip = true;
}
} else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
if (mWidth < mHeight) {
skip = true;
}
}
// Special Patch for Square Resolution: Black Berry Passport
if (skip) {
double min = Math.min(mWidth, mHeight);
double max = Math.max(mWidth, mHeight);
if (max / min < 1.20) {
Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
skip = false;
}
}
// Don't skip in MultiWindow.
if (skip) {
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
if (SDLActivity.mSingleton.isInMultiWindowMode()) {
Log.v("SDL", "Don't skip in Multi-Window");
skip = false;
}
}
}
if (skip) {
Log.v("SDL", "Skip .. Surface is not ready.");
mIsSurfaceReady = false;
return;
}
/* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
SDLActivity.onNativeSurfaceChanged();
/* Surface is ready */
mIsSurfaceReady = true;
SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
SDLActivity.handleNativeState();
}
// Key events
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
return SDLActivity.handleKeyEvent(v, keyCode, event, null);
}
// Touch events
@Override
public boolean onTouch(View v, MotionEvent event) {
/* Ref: http://developer.android.com/training/gestures/multi.html */
int touchDevId = event.getDeviceId();
final int pointerCount = event.getPointerCount();
int action = event.getActionMasked();
int pointerFingerId;
int i = -1;
float x,y,p;
/*
* Prevent id to be -1, since it's used in SDL internal for synthetic events
* Appears when using Android emulator, eg:
* adb shell input mouse tap 100 100
* adb shell input touchscreen tap 100 100
*/
if (touchDevId < 0) {
touchDevId -= 1;
}
// 12290 = Samsung DeX mode desktop mouse
// 12290 = 0x3002 = 0x2002 | 0x1002 = SOURCE_MOUSE | SOURCE_TOUCHSCREEN
// 0x2 = SOURCE_CLASS_POINTER
if (event.getSource() == InputDevice.SOURCE_MOUSE || event.getSource() == (InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN)) {
int mouseButton = 1;
try {
Object object = event.getClass().getMethod("getButtonState").invoke(event);
if (object != null) {
mouseButton = (Integer) object;
}
} catch(Exception ignored) {
}
// We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
// if we are. We'll leverage our existing mouse motion listener
SDLGenericMotionListener_API12 motionListener = SDLActivity.getMotionListener();
x = motionListener.getEventX(event);
y = motionListener.getEventY(event);
SDLActivity.onNativeMouse(mouseButton, action, x, y, motionListener.inRelativeMode());
} else {
switch(action) {
case MotionEvent.ACTION_MOVE:
for (i = 0; i < pointerCount; i++) {
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_DOWN:
// Primary pointer up/down, the index is always zero
i = 0;
/* fallthrough */
case MotionEvent.ACTION_POINTER_UP:
case MotionEvent.ACTION_POINTER_DOWN:
// Non primary pointer up/down
if (i == -1) {
i = event.getActionIndex();
}
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
break;
case MotionEvent.ACTION_CANCEL:
for (i = 0; i < pointerCount; i++) {
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
}
break;
default:
break;
}
}
return true;
}
// Sensor events
public void enableSensor(int sensortype, boolean enabled) {
// TODO: This uses getDefaultSensor - what if we have >1 accels?
if (enabled) {
mSensorManager.registerListener(this,
mSensorManager.getDefaultSensor(sensortype),
SensorManager.SENSOR_DELAY_GAME, null);
} else {
mSensorManager.unregisterListener(this,
mSensorManager.getDefaultSensor(sensortype));
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// TODO
}
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
// Since we may have an orientation set, we won't receive onConfigurationChanged events.
// We thus should check here.
int newOrientation;
float x, y;
switch (mDisplay.getRotation()) {
case Surface.ROTATION_90:
x = -event.values[1];
y = event.values[0];
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE;
break;
case Surface.ROTATION_270:
x = event.values[1];
y = -event.values[0];
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE_FLIPPED;
break;
case Surface.ROTATION_180:
x = -event.values[0];
y = -event.values[1];
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT_FLIPPED;
break;
case Surface.ROTATION_0:
default:
x = event.values[0];
y = event.values[1];
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT;
break;
}
if (newOrientation != SDLActivity.mCurrentOrientation) {
SDLActivity.mCurrentOrientation = newOrientation;
SDLActivity.onNativeOrientationChanged(newOrientation);
}
SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
y / SensorManager.GRAVITY_EARTH,
event.values[2] / SensorManager.GRAVITY_EARTH);
}
}
// Captured pointer events for API 26.
public boolean onCapturedPointerEvent(MotionEvent event)
{
int action = event.getActionMasked();
float x, y;
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
case MotionEvent.ACTION_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
case MotionEvent.ACTION_BUTTON_PRESS:
case MotionEvent.ACTION_BUTTON_RELEASE:
// Change our action value to what SDL's code expects.
if (action == MotionEvent.ACTION_BUTTON_PRESS) {
action = MotionEvent.ACTION_DOWN;
} else { /* MotionEvent.ACTION_BUTTON_RELEASE */
action = MotionEvent.ACTION_UP;
}
x = event.getX(0);
y = event.getY(0);
int button = event.getButtonState();
SDLActivity.onNativeMouse(button, action, x, y, true);
return true;
}
return false;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">QuestShock</string>
</resources>
+36
View File
@@ -0,0 +1,36 @@
// Top-level Gradle build file. Adapted from SDL2's own android-project
// template (see app/src/main/java/org/libsdl/app/, copied from the same
// template). Unlike the C/SDL2 desktop build, this needs network access
// at build time for Gradle/AGP's own dependency resolution - see
// build-apk.sh.
buildscript {
repositories {
mavenCentral()
google()
}
dependencies {
// 8.5.1+ needed for AGP to automatically 16 KB-align native
// libraries' zip entries within the APK - the actual .so's are
// already ELF-segment-aligned for this regardless of AGP version
// (see build-image/Dockerfile's ANDROID_16KB_LDFLAGS and
// app/build.gradle's own CMAKE_SHARED_LINKER_FLAGS), but that alone
// doesn't get them 16 KB-aligned *within the APK's zip*, which is
// what Android Studio's "not compatible with 16 KB devices"
// warning is actually about. Needs Gradle 8.7+ (see
// gradle/wrapper/gradle-wrapper.properties) and JDK 17 in whatever
// runs gradlew (already the case - see build-image/Dockerfile's
// openjdk-17-jdk-headless).
classpath 'com.android.tools.build:gradle:8.5.2'
}
}
allprojects {
repositories {
mavenCentral()
google()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,55 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -380,9 +380,25 @@
-include precompiled.h
)
-add_executable(systemshock
- ${MAC_SRC}
-)
+# Android has no standalone executables (Java loads a shared library via
+# JNI instead), so this is the one part of this file Android needs to
+# override - see android/engine-patches/. Everything else here (sources,
+# dependency discovery, link libraries) is untouched and applies the same
+# way on both platforms. ANDROID_EXTRA_SOURCES (passed in by
+# android/app/build.gradle) is QuestShockActivity's own small native
+# helper (android/app/src/main/cpp/) - not part of engine/ itself.
+if(ANDROID)
+ set(SHOCKOLATE_TARGET main)
+ add_library(${SHOCKOLATE_TARGET} SHARED
+ ${MAC_SRC}
+ ${ANDROID_EXTRA_SOURCES}
+ )
+else()
+ set(SHOCKOLATE_TARGET systemshock)
+ add_executable(${SHOCKOLATE_TARGET}
+ ${MAC_SRC}
+ )
+endif()
add_library(GAME_LIB ${GAME_SRC})
@@ -391,7 +407,7 @@
set(WINDOWS_LIBRARIES "mingw32 -mwindows")
endif(MINGW)
-target_link_libraries(systemshock
+target_link_libraries(${SHOCKOLATE_TARGET}
${WINDOWS_LIBRARIES} # Set it before any linker options! Beware WinMain@16 error!!
GAME_LIB
UI_LIB
@@ -418,7 +434,10 @@
${ALSA_LIBRARIES}
)
-# Turn on address sanitizing if wanted
-set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/externals/sanitizers/cmake" ${CMAKE_MODULE_PATH})
-find_package(Sanitizers)
-add_sanitizers(systemshock)
+# Turn on address sanitizing if wanted (desktop only - not meaningful for
+# an Android release build)
+if(NOT ANDROID)
+ set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/externals/sanitizers/cmake" ${CMAKE_MODULE_PATH})
+ find_package(Sanitizers)
+ add_sanitizers(${SHOCKOLATE_TARGET})
+endif()
@@ -0,0 +1,33 @@
diff -ru a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt 2026-07-31 14:04:53.195646458 +0200
+++ b/CMakeLists.txt 2026-07-31 14:05:05.703429751 +0200
@@ -46,9 +46,27 @@
add_compile_options(-fsigned-char -fno-strict-aliasing)
-# Find OpenGL
+# Find OpenGL. Android has no desktop GL/GLX for CMake's FindOpenGL module
+# to find - build GL4ES from source instead (translates this file's
+# desktop-style GL calls into GLES/EGL; it links GLESv2/EGL itself, so
+# the engine doesn't need to link them directly), via add_subdirectory
+# rather than a prebuilt .so: ANDROID_GL4ES_DIR (passed in by
+# android/app/build.gradle, same as ANDROID_PREBUILT_DIR) is a scratch,
+# patched copy of android/gl4es-src/ staged by
+# build-image/prepare-android-project.sh (mirrors how this file itself is
+# staged/patched before Gradle ever sees it) - building it as part of this
+# same CMake configure means Gradle's own incremental CMake/Ninja build
+# only recompiles it when gl4es-src/gl4es-patches actually changed, same
+# as this file, instead of needing a full build-image rebuild for every
+# gl4es-patches change.
if(ENABLE_OPENGL)
- find_package(OpenGL REQUIRED)
+ if(ANDROID)
+ add_subdirectory(${ANDROID_GL4ES_DIR} ${CMAKE_BINARY_DIR}/gl4es-build)
+ set(OPENGL_INCLUDE_DIRS ${ANDROID_GL4ES_DIR}/include)
+ set(OPENGL_LIBRARIES GL)
+ else()
+ find_package(OpenGL REQUIRED)
+ endif()
add_definitions(-DUSE_OPENGL)
if(WIN32)
list(APPEND OPENGL_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/build_ext/built_glew/include)
@@ -0,0 +1,24 @@
--- a/src/MacSrc/Shock.c 2026-07-19 21:10:52.654910439 +0200
+++ b/src/MacSrc/Shock.c 2026-07-19 21:10:52.658910394 +0200
@@ -167,9 +167,21 @@
}
// TODO: figure out some universal set of settings that work...
+#ifdef __ANDROID__
+ // Desktop GL's compatibility/core profile split doesn't exist on
+ // Android - EGL only ever gives out GLES contexts. Quest hardware
+ // (Adreno on the XR2/XR2 Gen 2) supports GLES 3.2, which - unlike
+ // GLES 2.0 - has GL_UNPACK_ROW_LENGTH and GL_CLAMP_TO_BORDER as core
+ // (see android/engine-patches/04-android-opengl-es-render.patch),
+ // avoiding needing workarounds for either in OpenGL.cc.
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
+#else
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
+#endif
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
@@ -0,0 +1,75 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -20,6 +20,24 @@
#include <SDL_opengl.h>
#endif
+#ifdef __ANDROID__
+// GL4ES's glPointSize()/custom-shader interop goes through the same kind
+// of shader-source rewrite as its alpha-test emulation (see
+// init_opengl() below) - avoid relying on it. GLES point size instead
+// comes from a "pointSize" uniform, written to gl_PointSize in
+// android/gles-shaders/main.vert. opengl_begin_stars() (the only
+// glPointSize() call site) now binds the star shader first, so
+// GL_CURRENT_PROGRAM here is always correct.
+#define glPointSize questshock_glPointSize
+static void questshock_glPointSize(GLfloat size) {
+ GLint program = 0;
+ glGetIntegerv(GL_CURRENT_PROGRAM, &program);
+ GLint loc = glGetUniformLocation(program, "pointSize");
+ if (loc >= 0)
+ glUniform1f(loc, size);
+}
+#endif // __ANDROID__
+
extern "C" {
#include "mainloop.h"
#include "map.h"
@@ -229,6 +247,13 @@
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertShader);
glAttachShader(shaderProgram, fragShader);
+#ifdef __ANDROID__
+ // Must be bound before linking. GL4ES routes glVertex3f() (used
+ // throughout this file below) to attribute location 0 of whatever
+ // shader is currently bound - matches
+ // android/gles-shaders/main.vert's "position" attribute.
+ glBindAttribLocation(shaderProgram, 0, "position");
+#endif
glLinkProgram(shaderProgram);
glUseProgram(shaderProgram);
@@ -313,10 +338,18 @@
glEnable(GL_CULL_FACE);
glEnable(GL_BLEND);
+#ifndef __ANDROID__
+ // GL4ES emulates both of these (alpha test, point sprites) for a
+ // custom shader by rewriting its source under the hood - a rougher
+ // edge of the library. Skip relying on that: alpha test is instead
+ // a discard in android/gles-shaders/texture.frag, and point-sprite
+ // rasterization for GL_POINTS works automatically in GLES with no
+ // enable call at all (see android/gles-shaders/star.frag).
glEnable(GL_ALPHA_TEST);
glEnable(GL_POINT_SPRITE);
- glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glAlphaFunc(GL_GEQUAL, 0.05f);
+#endif
+ glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
CreateShader("main.vert", "texture.frag", &textureShaderProgram);
CreateShader("main.vert", "color.frag", &colorShaderProgram);
@@ -908,9 +941,12 @@
void opengl_begin_stars() {
SDL_GL_MakeCurrent(window, context);
+ // Bind the star shader before setting the point size: on Android,
+ // questshock_glPointSize() (see the __ANDROID__ block above) needs
+ // it to already be GL_CURRENT_PROGRAM.
+ glUseProgram(starShaderProgram.shaderProgram);
glPointSize(1.5 * (render_width / 320.0));
- glUseProgram(starShaderProgram.shaderProgram);
glUniformMatrix4fv(starShaderProgram.uniView, 1, false, IdentityMatrix);
glUniformMatrix4fv(starShaderProgram.uniProj, 1, false, IdentityMatrix);
@@ -0,0 +1,29 @@
--- a/src/MacSrc/Shock.c
+++ b/src/MacSrc/Shock.c
@@ -162,9 +162,26 @@
void InitSDL() {
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
+#ifdef __ANDROID__
+ // SDL2 2.28.5's AAudio backend (Android's default since API 26) only
+ // ever allows one open non-capture (playback) device at a time - it
+ // keeps a single static handle and asserts on a second open
+ // ('SDL_assert((audioDevice == NULL) || iscapture)' in
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
+ // it instead of AAudio. Uses SDL_HINT_OVERRIDE priority so nothing set
+ // earlier (or by SDL/the platform) can take precedence over it.
+ SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, "openslES", SDL_HINT_OVERRIDE);
+#endif
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
DEBUG("%s: Init failed", __FUNCTION__);
}
+#ifdef __ANDROID__
+ // Standing diagnostic: reports which audio driver SDL actually selected,
+ // to confirm the openslES override above took effect on-device.
+ INFO("SDL audio driver: %s", SDL_GetCurrentAudioDriver());
+#endif
// TODO: figure out some universal set of settings that work...
#ifdef __ANDROID__
@@ -0,0 +1,27 @@
--- a/src/Libraries/INPUT/Source/sdl_events.c
+++ b/src/Libraries/INPUT/Source/sdl_events.c
@@ -737,7 +737,24 @@
break;
case SDL_WINDOWEVENT_MOVED:
+ break;
+
case SDL_WINDOWEVENT_RESIZED:
+#ifdef __ANDROID__
+ // Android's SDL video backend (Android_SendResize() in
+ // src/video/android/SDL_androidvideo.c) only ever sends
+ // RESIZED for surface-driven resizes - e.g. the Quest Home
+ // shell settling the 2D panel into its requested <layout>
+ // defaultWidth/defaultHeight after activity launch - never
+ // SIZE_CHANGED (unlike desktop platforms, where an
+ // app-driven SDL_SetWindowSize() triggers both, handled
+ // above). Without this, opengl_resize() never re-runs after
+ // that late resize, leaving the GL viewport stuck at
+ // whatever (smaller) size the window first reported,
+ // rendered into one corner of the now-larger surface.
+ if (can_use_opengl())
+ opengl_resize(ev.window.data1, ev.window.data2);
+#endif
break;
case SDL_WINDOWEVENT_FOCUS_GAINED:
@@ -0,0 +1,10 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -440,6 +440,7 @@
${FLUIDSYNTH_LIBRARIES}
${OPENGL_LIBRARIES}
${ALSA_LIBRARIES}
+ $<$<BOOL:${ANDROID}>:log>
)
# Turn on address sanitizing if wanted (desktop only - not meaningful for
@@ -0,0 +1,45 @@
--- a/src/Libraries/LG/Source/LOG/src/log.c
+++ b/src/Libraries/LG/Source/LOG/src/log.c
@@ -28,6 +28,10 @@
#include "log.h"
+#ifdef __ANDROID__
+#include <android/log.h>
+#endif
+
static struct {
void *udata;
log_LockFn lock;
@@ -99,6 +103,23 @@
time_t t = time(NULL);
struct tm *lt = localtime(&t);
+#ifdef __ANDROID__
+ /* Plain stdout/stderr isn't captured by logcat on this platform (unlike
+ e.g. gl4es's own "LIBGL"-tagged logging, which goes through this same
+ API directly) - route there instead so every existing INFO/DEBUG/WARN/
+ ERROR call site in the engine becomes visible for on-device debugging,
+ with no changes needed at those call sites. */
+ if (!L.quiet) {
+ static const int android_priority[] = {
+ ANDROID_LOG_VERBOSE, ANDROID_LOG_DEBUG, ANDROID_LOG_INFO,
+ ANDROID_LOG_WARN, ANDROID_LOG_ERROR, ANDROID_LOG_FATAL
+ };
+ va_list args;
+ va_start(args, fmt);
+ __android_log_vprint(android_priority[level], "QuestShock", fmt, args);
+ va_end(args);
+ }
+#else
/* Log to stderr */
if (!L.quiet) {
va_list args;
@@ -116,6 +137,7 @@
va_end(args);
fprintf(stderr, "\n");
}
+#endif
/* Log to file */
if (L.fp) {
@@ -0,0 +1,68 @@
--- a/src/MacSrc/ShockBitmap.c
+++ b/src/MacSrc/ShockBitmap.c
@@ -27,6 +27,9 @@
#include "Shock.h"
#include "ShockBitmap.h"
#include "2d.h"
+#ifdef __ANDROID__
+#include "OpenGL.h"
+#endif
//--------------------
// Globals
@@ -42,11 +45,26 @@
SDL_RenderClear(renderer);
+#ifndef __ANDROID__
+ // On Android there's exactly one OS-controlled-size surface - no
+ // desktop-style window to resize, move, or toggle fullscreen on.
+ // SDL_SetWindowSize() still "succeeds" there (Android has no
+ // SetWindowSize driver hook, so SDL's generic layer just overwrites its
+ // own cached window->w/h to the requested game resolution, e.g.
+ // 640x480, and synthesizes a resize event from that) - which desyncs
+ // SDL's own notion of the window size from the real, unchanged Android
+ // surface size (e.g. 1600x1200), and that desync is exactly what then
+ // makes both SDL's internal renderer viewport and this engine's own
+ // custom GL viewport (OpenGL.cc's opengl_resize(), driven by that same
+ // now-wrong cached size) shrink to the game's resolution instead of
+ // filling the real surface. Skip the desktop-only calls on Android
+ // entirely, leaving SDL's window-size bookkeeping untouched.
extern bool fullscreenActive;
SDL_SetWindowFullscreen(window, fullscreenActive ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0);
SDL_SetWindowSize(window, width, height);
SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
+#endif
SDL_RenderSetLogicalSize(renderer, width, height);
@@ -54,6 +72,28 @@
gScreenWide = width;
gScreenHigh = height;
+
+#ifdef __ANDROID__
+ // Skipping SDL_SetWindowSize() above (see the comment for why) also
+ // means no native resize event ever fires on Android when the in-game
+ // resolution changes - opengl_resize() (OpenGL.cc) only ever runs from
+ // that event (see sdl_events.c) or once at OpenGL init, and it's what
+ // (re)creates backupBuffer, the offscreen 3D-view render target, sized
+ // to the logical resolution just changed above. Without this, it stays
+ // stuck at whatever size it was at launch while the rest of the game
+ // (drawSurface/gScreenWide/gScreenHigh, just updated above) moves on to
+ // the new resolution - the actual cause of the display breaking and
+ // becoming unresponsive to further input after changing resolution
+ // in-game. Call it directly instead - after gScreenWide/gScreenHigh
+ // update above, same as the desktop resize event (delivered via
+ // SDL_PollEvent(), so always processed after this function has already
+ // returned) - using the same physical window size init_opengl() itself
+ // used (SDL_GetWindowSize()); that never changes on Android, only the
+ // logical size set above does.
+ int physWidth, physHeight;
+ SDL_GetWindowSize(window, &physWidth, &physHeight);
+ opengl_resize(physWidth, physHeight);
+#endif
}
//------------------------------------------------------------------------------------
@@ -0,0 +1,39 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -64,6 +64,19 @@
endif(WIN32)
endif(ENABLE_OPENGL)
+# OpenXR loader (Android only for now - see android/app/src/main/cpp/
+# xr_session.c, which submits the game's existing render as a composition
+# layer instead of presenting to a normal window/surface). EGL is linked
+# explicitly too - xr_session.c calls eglGetCurrentDisplay/Context/
+# QueryContext/ChooseConfig directly (to share the GL context gl4es/SDL
+# already made), and unlike GL itself (where gl4es's own libGL.so already
+# provides every symbol the engine or xr_session.c call), there's no other
+# EGL provider in this link.
+if(ANDROID)
+ set(OPENXR_INCLUDE_DIRS ${ANDROID_PREBUILT_DIR}/openxr/include)
+ set(OPENXR_LIBRARIES ${ANDROID_PREBUILT_DIR}/openxr/lib/libopenxr_loader.so EGL)
+endif(ANDROID)
+
if(ENABLE_SDL2 MATCHES "ON")
find_package(SDL2 REQUIRED)
if(SDL2_FOUND)
@@ -109,6 +122,8 @@
${SDL2_MIXER_INCLUDE_DIRS}
${FLUIDSYNTH_INCLUDE_DIRS}
${OPENGL_INCLUDE_DIRS}
+ ${OPENXR_INCLUDE_DIRS}
+ ${ANDROID_EXTRA_INCLUDE_DIRS}
)
if(NOT WIN32)
@@ -439,6 +454,7 @@
${SDL2_MIXER_LIBRARIES}
${FLUIDSYNTH_LIBRARIES}
${OPENGL_LIBRARIES}
+ ${OPENXR_LIBRARIES}
${ALSA_LIBRARIES}
$<$<BOOL:${ANDROID}>:log>
)
@@ -0,0 +1,267 @@
--- a/src/MacSrc/OpenGL.cc 2026-07-25 07:10:06.189412183 +0200
+++ b/src/MacSrc/OpenGL.cc 2026-07-25 07:12:05.356301052 +0200
@@ -36,6 +36,7 @@
if (loc >= 0)
glUniform1f(loc, size);
}
+#include "xr_session.h"
#endif // __ANDROID__
extern "C" {
@@ -382,6 +383,21 @@
#endif
opengl_resize(width, height);
+#ifdef __ANDROID__
+ // Bring up the OpenXR session now that the shared GL context above is
+ // current (xr_init() reuses it via eglGetCurrentContext/Display - see
+ // xr_session.c) - sized to the game's own logical resolution, not the
+ // physical window, since the swapchain is composited by the XR runtime
+ // rather than upscaled by us into a physical-window-sized viewport (see
+ // opengl_swap_and_restore()/android_composite_software_frame() below).
+ // Failure here (e.g. no OpenXR runtime installed) just means the
+ // immersive path never activates - SDLDraw() falls back to the plain
+ // window-present path for the rest of the run.
+ int xr_logical_width, xr_logical_height;
+ SDL_RenderGetLogicalSize(renderer, &xr_logical_width, &xr_logical_height);
+ xr_init(xr_logical_width, xr_logical_height);
+#endif
+
// Now make the palettes
opaquePalette = SDL_AllocPalette(256);
transparentPalette = SDL_AllocPalette(256);
@@ -498,7 +514,109 @@
*y_scale = output_height / screen_height;
}
+#ifdef __ANDROID__
+// Shared by opengl_swap_and_restore()'s UI-overlay blit and
+// android_composite_software_frame() below - draws an SDL_Surface as a
+// fullscreen textured quad via gl4es. SDL's own renderer can't be used
+// here: it's tied to the window's own EGL surface, not whatever XR
+// swapchain framebuffer the caller already bound via xr_frame_begin().
+static void android_draw_surface_as_quad(SDL_Surface *ui, bool blend) {
+ SDL_Surface *uiRgba = SDL_ConvertSurfaceFormat(ui, SDL_PIXELFORMAT_RGBA32, 0);
+ if (uiRgba == nullptr)
+ return;
+
+ glUseProgram(textureShaderProgram.shaderProgram);
+ GLint tcAttrib = textureShaderProgram.tcAttrib;
+ GLint lightAttrib = textureShaderProgram.lightAttrib;
+ glUniform1i(textureShaderProgram.uniNightSight, false);
+ glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, IdentityMatrix);
+ glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, IdentityMatrix);
+
+ // Persistent texture, reused every call instead of a fresh
+ // gen/upload/delete each frame.
+ static GLuint s_uiTexture = 0;
+ if (s_uiTexture == 0)
+ glGenTextures(1, &s_uiTexture);
+ bind_texture(s_uiTexture);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ uiRgba->pixels);
+
+ set_blend_mode(blend);
+
+ // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
+ // immediate mode: glVertexAttrib*() is GLES2's *constant*-attribute
+ // API - without an enabled vertex array it sets one value for the
+ // whole draw call, not a fresh value per vertex. gl4es's immediate-mode
+ // emulation only reproduces desktop GL's per-vertex-capture behavior
+ // for attribute 0 (position, driven directly by glVertex3f), not for
+ // custom attributes like this shader's texcoords/light.
+ //
+ // V is flipped (1 at the bottom, 0 at the top): GL texture v=0
+ // addresses the first row of data passed to glTexImage2D, but uiRgba
+ // (an SDL surface) stores its rows top-down.
+ struct QuadVertex {
+ float x, y, z;
+ float u, v;
+ float light;
+ };
+ static const QuadVertex quadVerts[4] = {
+ {1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f},
+ {1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
+ {-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f},
+ {-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f},
+ };
+ glEnableVertexAttribArray(0);
+ glEnableVertexAttribArray(tcAttrib);
+ glEnableVertexAttribArray(lightAttrib);
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].x);
+ glVertexAttribPointer(tcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].u);
+ glVertexAttribPointer(lightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(QuadVertex),
+ &quadVerts[0].light);
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+ // The engine's other rendering uses glBegin/glVertexAttrib/glVertex3f
+ // immediate mode for attribute 0, which depends on it not having an
+ // enabled array bound.
+ glDisableVertexAttribArray(0);
+ glDisableVertexAttribArray(tcAttrib);
+ glDisableVertexAttribArray(lightAttrib);
+ glFlush();
+
+ SDL_FreeSurface(uiRgba);
+}
+
+void android_composite_software_frame(SDL_Surface *ui) {
+ // Deliberately no SDL_GL_MakeCurrent() here - see the comment in
+ // opengl_swap_and_restore() below for why.
+ glClear(GL_COLOR_BUFFER_BIT);
+ android_draw_surface_as_quad(ui, false);
+}
+#endif
+
void opengl_swap_and_restore(SDL_Surface *ui) {
+#ifdef __ANDROID__
+ // No SDL_GL_MakeCurrent() here on Android, deliberately - unlike the
+ // desktop path below. init_opengl() already made (window, context)
+ // current once; re-asserting it every frame turned out to reset
+ // gl4es's own internal "current framebuffer" bookkeeping back to
+ // whatever it associates with that context/surface pair (observed
+ // on-device: the XR swapchain image reliably received our real
+ // glClear/bind from xr_frame_begin(), but every actual gl4es-routed
+ // draw call after a fresh SDL_GL_MakeCurrent() here kept landing
+ // somewhere else - the quad stayed permanently black). Confirmed this
+ // exact class of gl4es/OpenXR interaction against RTCWQuest (Team
+ // Beef Studios), a shipped gl4es+Khronos-OpenXR-loader Quest port:
+ // it makes its EGL context current exactly once at init and never
+ // again per-frame, for the same reason.
+ //
+ // xr_frame_begin() (called from SDLDraw()) already bound the XR
+ // swapchain's framebuffer and a full-size viewport - no physical-
+ // window letterboxing needed here either, unlike the desktop/2D-panel
+ // path below, since the swapchain is sized to exactly the game's
+ // logical resolution (see xr_init()).
+ glClear(GL_COLOR_BUFFER_BIT);
+#else
// restore the view backup (without HUD overlay) for incremental
// updates in the subsequent frame
SDL_GL_MakeCurrent(window, context);
@@ -510,6 +628,7 @@
// Set the drawable area for the 3d view
glViewport(phys_offset_x * x_hdpi_scale, phys_offset_y * y_hdpi_scale, phys_width * x_hdpi_scale,
phys_height * y_hdpi_scale);
+#endif
set_blend_mode(false);
// Bind and setup our general shader program
@@ -552,6 +671,11 @@
if (err != GL_NO_ERROR)
ERROR("OpenGL error: %i", err);
+#ifdef __ANDROID__
+ // Blit the UI canvas over the 3d view (see android_draw_surface_as_quad()
+ // above - SDL's renderer can't target the XR swapchain framebuffer).
+ android_draw_surface_as_quad(ui, true);
+#else
// Blit the UI canvas over the 3d view
SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, ui);
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
@@ -561,6 +685,7 @@
// Finally, swap to the screen
SDL_RenderPresent(renderer);
+#endif
}
void toggle_opengl() {
--- a/src/MacSrc/OpenGL.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/OpenGL.h 2026-07-25 07:10:06.353410581 +0200
@@ -22,6 +22,15 @@
void opengl_swap_and_restore(SDL_Surface *ui);
void opengl_change_palette();
+#ifdef __ANDROID__
+// Composites a plain software-rendered frame (the same content SDLDraw's
+// non-OpenGL fallback would otherwise present via SDL_RenderCopy) into the
+// XR swapchain image already bound by xr_frame_begin() - see Shock.c's
+// SDLDraw(), which needs this for the splash screen/cutscenes/menus that
+// render this way instead of through opengl_swap_and_restore().
+void android_composite_software_frame(SDL_Surface *ui);
+#endif
+
void opengl_set_viewport(int x, int y, int width, int height);
int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
int opengl_light_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
--- a/src/MacSrc/Shock.c 2026-07-25 07:10:06.181412261 +0200
+++ b/src/MacSrc/Shock.c 2026-07-25 07:10:06.353410581 +0200
@@ -30,6 +30,10 @@
#include <math.h>
#include <SDL.h>
+#ifdef __ANDROID__
+#include "xr_session.h"
+#endif
+
#include "InitMac.h"
#include "Modding.h"
#include "OpenGL.h"
@@ -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
+ // screen/cutscenes/menus, which render through the plain software
+ // path below, not the OpenGL one). xr_poll_events() needs exactly that
+ // - called unconditionally - to ever observe the session reach a
+ // running state in the first place.
+ xr_poll_events();
+ if (!xr_frame_begin()) {
+ xr_frame_end();
+ return;
+ }
+#endif
+
if (should_opengl_swap()) {
// We want the UI background to be transparent!
sdlPalette->colors[255].a = 0x00;
@@ -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
+ // renderer is tied to the window's own EGL surface, not that FBO.
+ android_composite_software_frame(drawSurface);
+ xr_frame_end();
+ return;
+#endif
+
// Clear the screen!
SDL_RenderClear(renderer);
SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, drawSurface);
- // Blit to the screen by drawing the surface
+ // Blit to the screen by drawing the surface
SDL_Rect srcRect = {0, 0, gScreenWide, gScreenHigh};
SDL_RenderCopy(renderer, texture, &srcRect, NULL);
SDL_DestroyTexture(texture);
@@ -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);
}
}
@@ -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);
}
@@ -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);
@@ -0,0 +1,11 @@
--- a/src/MacSrc/SDLSound.c
+++ b/src/MacSrc/SDLSound.c
@@ -42,7 +42,7 @@
}
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
}
Mix_AllocateChannels(SND_MAX_SAMPLES);
@@ -0,0 +1,181 @@
--- a/src/MacSrc/SDLSound.c
+++ b/src/MacSrc/SDLSound.c
@@ -19,24 +19,6 @@
// Startup the sound system
- SDL_AudioSpec spec, obtained;
- spec.freq = 48000;
- spec.format = AUDIO_S16SYS;
- spec.channels = 2;
- spec.samples = 2048;
- spec.callback = AudioStreamCallback;
- spec.userdata = (void *)&cutscene_audiostream;
-
- extern SDL_AudioDeviceID device;
- device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0);
-
- if (device == 0) {
- ERROR("Could not open SDL audio: %s", SDL_GetError());
- } else {
- INFO("Opened Music Stream, deviceID %d, freq %d, size %d, format %d, channels %d, samples %d", device,
- obtained.freq, obtained.size, obtained.format, obtained.channels, obtained.samples);
- }
-
if (Mix_Init(MIX_INIT_MP3) < 0) {
ERROR("%s: Init failed", __FUNCTION__);
}
@@ -50,10 +32,18 @@
Mix_HookMusic(MusicCallback, (void *)&MusicDev);
Mix_VolumeMusic(MIX_MAX_VOLUME); // use max volume for music stream
+ // Cutscene/audiolog playback (AudioStreamCallback, see cutsloop.c) used to
+ // run through its own SDL_OpenAudioDevice() device, separate from this
+ // one - SDL only ever allows one open non-capture device on Android's
+ // audio backends, so that second open always failed with "Audio device
+ // already open" and both SFX and MIDI music (both routed through this
+ // Mix_OpenAudio device) silently never played. Mixed into this device's
+ // post-mix stage instead, so there is only ever one device open.
+ Mix_SetPostMix(AudioStreamCallback, (void *)&cutscene_audiostream);
+
InitReadXMI();
atexit(Mix_CloseAudio);
- atexit(SDL_CloseAudio);
return OK;
}
--- a/src/MacSrc/Shock.c
+++ b/src/MacSrc/Shock.c
@@ -60,8 +60,6 @@
SDL_Palette *sdlPalette;
SDL_Renderer *renderer;
-SDL_AudioDeviceID device;
-
int num_args;
char **arg_values;
--- a/src/GameSrc/cutsloop.c
+++ b/src/GameSrc/cutsloop.c
@@ -83,15 +83,25 @@
extern char FrnSubtitle[256];
extern char GerSubtitle[256];
-extern SDL_AudioDeviceID device;
-
-
+// Runs as SDL_mixer's post-mix hook (see snd_start_digital() in
+// SDLSound.c) rather than a dedicated SDL audio device's callback, so
+// SFX/MIDI music sharing the same device are already mixed into `stream`
+// by the time this runs - mix in rather than overwrite, so they aren't
+// silenced whenever a cutscene or audiolog is playing.
void AudioStreamCallback(void *userdata, unsigned char *stream, int len)
{
+ static Uint8 mixbuf[MOVIE_DEFAULT_BLOCKLEN * 4];
SDL_AudioStream *as = *(SDL_AudioStream **)userdata;
- if (as != NULL && SDL_AudioStreamAvailable(as) > 0)
- SDL_AudioStreamGet(as, stream, len);
+ if (as == NULL || SDL_AudioStreamAvailable(as) <= 0)
+ return;
+
+ if (len > (int)sizeof(mixbuf))
+ len = (int)sizeof(mixbuf);
+
+ int got = SDL_AudioStreamGet(as, mixbuf, len);
+ if (got > 0)
+ SDL_MixAudioFormat(stream, mixbuf, AUDIO_S16SYS, got, SDL_MIX_MAXVOLUME);
}
@@ -153,11 +163,13 @@
if (cutscene_audiostream != NULL)
{
- SDL_PauseAudioDevice(device, 1);
- SDL_Delay(1);
-
+ // AudioStreamCallback (SDL_mixer's post-mix hook) runs continuously on
+ // SDL_mixer's own audio thread and reads this pointer - lock around
+ // freeing it so that thread can't be mid-read when it's freed.
+ SDL_LockAudio();
SDL_FreeAudioStream(cutscene_audiostream);
cutscene_audiostream = NULL;
+ SDL_UnlockAudio();
if (cutscene_audiobuffer)
{
@@ -184,8 +196,6 @@
if (cutscene_audiostream)
{
- SDL_PauseAudioDevice(device, 0);
-
if (cutscene_audiobuffer_size > 0)
{
// === adjust volume in buffer here ===
@@ -325,10 +335,9 @@
if (sfx_on)
{
- SDL_PauseAudioDevice(device, 1);
- SDL_Delay(1);
-
+ SDL_LockAudio();
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(amovie->a.sampleRate), AUDIO_S16SYS, 2, 48000);
+ SDL_UnlockAudio();
cutscene_audiobuffer_pos = cutscene_audiobuffer;
}
--- a/src/GameSrc/audiolog.c
+++ b/src/GameSrc/audiolog.c
@@ -61,8 +61,6 @@
extern uchar curr_alog_vol;
extern char which_lang;
-extern SDL_AudioDeviceID device;
-
errtype audiolog_init(void) { return OK; }
errtype audiolog_play(int email_id) {
@@ -114,10 +112,9 @@
DEBUG("%s: Playing email", __FUNCTION__);
- SDL_PauseAudioDevice(device, 1);
- SDL_Delay(1);
-
+ SDL_LockAudio();
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(palog->a.sampleRate), AUDIO_S16SYS, 2, 48000);
+ SDL_UnlockAudio();
audiolog_audiobuffer_pos = audiolog_audiobuffer;
@@ -150,11 +147,14 @@
}
if (cutscene_audiostream != NULL) {
- SDL_PauseAudioDevice(device, 1);
- SDL_Delay(1);
-
+ // AudioStreamCallback (SDL_mixer's post-mix hook, see cutsloop.c)
+ // runs continuously on SDL_mixer's own audio thread and reads this
+ // pointer - lock around freeing it so that thread can't be
+ // mid-read when it's freed.
+ SDL_LockAudio();
SDL_FreeAudioStream(cutscene_audiostream);
cutscene_audiostream = NULL;
+ SDL_UnlockAudio();
if (audiolog_audiobuffer) {
free(audiolog_audiobuffer);
@@ -177,8 +177,6 @@
errtype audiolog_loop_callback(void) {
if (cutscene_audiostream) {
- SDL_PauseAudioDevice(device, 0);
-
if (audiolog_audiobuffer_size > 0) {
int i, vol = curr_alog_vol * 127 / 100; // convert from 0-100 to 0-127
@@ -0,0 +1,17 @@
--- a/src/MusicSrc/MusicDevice.c
+++ b/src/MusicSrc/MusicDevice.c
@@ -569,6 +569,14 @@
// connected
ndev->dev.isOpen = 1;
ndev->dev.outputIndex = outputIndex;
+#else
+ // No native MIDI backend implemented for this platform (e.g. Android) -
+ // report failure so InitDecXMI()'s caller falls back to another device
+ // instead of treating an unopened device as successfully initialized.
+ (void)outputIndex;
+ (void)samplerate;
+ WARN("NativeMidiInit(): native MIDI is not implemented on this platform");
+ return -1;
#endif
// suppress compiler warnings
(void)outputIndex;
@@ -0,0 +1,455 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -391,7 +391,16 @@
}
void opengl_resize(int width, int height) {
+#ifndef __ANDROID__
+ // Skipped on Android - see opengl_swap_and_restore()'s comment below for
+ // why re-asserting an already-current context/surface every call breaks
+ // gl4es's own "current framebuffer" bookkeeping, even though the real
+ // driver binding never actually changes. This turned out to be the
+ // actual source of the persistent per-frame GL_INVALID_OPERATION (1282)
+ // that plagued 3D rendering: this same unconditional call was present at
+ // the top of nearly every function in this file, not just here.
SDL_GL_MakeCurrent(window, context);
+#endif
int logical_width, logical_height;
SDL_RenderGetLogicalSize(renderer, &logical_width, &logical_height);
@@ -443,7 +452,10 @@
}
void opengl_end_frame() {
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
// Done rendering to the frame buffer, reset back to normal
BindFrameBuffer(nullptr);
@@ -470,7 +482,10 @@
static bool nightsight_active() { return WareActive(player_struct.hardwarez_status[HARDWARE_GOGGLE_INFRARED]); }
void opengl_start_frame() {
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
// Start rendering to our frame buffer canvas
BindFrameBuffer(&backupBuffer);
@@ -500,6 +515,130 @@
}
#ifdef __ANDROID__
+// Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f immediate
+// mode: glVertexAttrib*() is GLES2's *constant*-attribute API - without an
+// enabled vertex array it sets one value for the whole draw call, not a
+// fresh value per vertex. gl4es's immediate-mode emulation only reproduces
+// desktop GL's per-vertex-capture behavior for attribute 0 (position,
+// driven directly by glVertex3f), not for custom attributes like this
+// shader's texcoords/light. That means every one of this file's
+// glBegin(GL_TRIANGLE_STRIP)-based draws - not just the UI-overlay quad
+// this was first found on, but also opengl_light_tmap() (every textured
+// wall/floor polygon), opengl_bitmap() (sprites) and opengl_draw_poly()
+// (flat/gouraud polygons) via draw_vertex() - draws every vertex with
+// whatever texcoord/light was set for the *last* vertex instead of its
+// own, which is what actually produced flat-colored/untextured world
+// geometry after this patch's first revision only fixed the final
+// screen-composite quad (the one bug this was originally found on).
+//
+// android_imm_begin()/android_imm_vertex()/android_imm_end() below emulate
+// just enough of glBegin(GL_TRIANGLE_STRIP)/glVertexAttrib*()/glVertex3f()
+// immediate mode to replace every such call site on Android, by buffering
+// vertices and flushing them as one real glDrawArrays() call - the engine's
+// other rendering still uses real immediate mode for attribute 0 elsewhere
+// on desktop builds, which depends on it not having an enabled array bound,
+// so android_imm_end() always disables the arrays it enables before
+// returning.
+struct AndroidImmVertex {
+ float x, y, z;
+ float u, v;
+ float light;
+};
+
+// Arbitrarily-sized, but generous: the engine's polygons (room walls/
+// floors, sprites, screen-filling quads) are all small, hand-authored
+// shapes, never anywhere near this many vertices.
+static AndroidImmVertex s_immVerts[64];
+static int s_immVertCount;
+static GLint s_immTcAttrib;
+static GLint s_immLightAttrib;
+
+// tcAttrib < 0 means this draw has no texcoord attribute (flat/gouraud
+// polygons via opengl_draw_poly()) - matches draw_vertex()'s own
+// `tcAttrib >= 0` check below.
+static void android_imm_begin(GLint tcAttrib, GLint lightAttrib) {
+ s_immVertCount = 0;
+ s_immTcAttrib = tcAttrib;
+ s_immLightAttrib = lightAttrib;
+}
+
+static void android_imm_vertex(float x, float y, float z, float u, float v, float light) {
+ if (s_immVertCount >= (int)(sizeof(s_immVerts) / sizeof(s_immVerts[0]))) {
+ WARN("android_imm_vertex: dropping vertex past the %d-vertex limit", s_immVertCount);
+ return;
+ }
+ AndroidImmVertex &vert = s_immVerts[s_immVertCount++];
+ vert.x = x;
+ vert.y = y;
+ vert.z = z;
+ vert.u = u;
+ vert.v = v;
+ vert.light = light;
+}
+
+static void android_imm_end() {
+ if (s_immVertCount < 3)
+ return;
+ glEnableVertexAttribArray(0);
+ if (s_immTcAttrib >= 0)
+ glEnableVertexAttribArray(s_immTcAttrib);
+ glEnableVertexAttribArray(s_immLightAttrib);
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_immVerts[0].x);
+ if (s_immTcAttrib >= 0)
+ glVertexAttribPointer(s_immTcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
+ &s_immVerts[0].u);
+ glVertexAttribPointer(s_immLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
+ &s_immVerts[0].light);
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, s_immVertCount);
+ glDisableVertexAttribArray(0);
+ if (s_immTcAttrib >= 0)
+ glDisableVertexAttribArray(s_immTcAttrib);
+ glDisableVertexAttribArray(s_immLightAttrib);
+}
+
+// Same underlying bug as android_imm_*() above, for opengl_draw_star():
+// opengl_begin_stars()/opengl_draw_star()/opengl_end_stars() draw up to
+// NUM_STARS (rendtool.c) individual GL_POINTS between one begin/end pair,
+// each with its own per-star brightness via glVertexAttrib1f(lightAttrib,
+// ...)/glVertex3f() immediate mode - under gl4es that made every star on
+// screen render at whatever brightness was set for the *last* one. Shaped
+// differently enough from android_imm_*() above (GL_POINTS instead of
+// GL_TRIANGLE_STRIP, far more vertices, no texcoord) to warrant its own
+// small buffer rather than overloading that one.
+static AndroidImmVertex s_starVerts[1000]; // NUM_STARS, GameSrc/rendtool.c
+static int s_starVertCount;
+static GLint s_starLightAttrib;
+
+static void android_stars_begin(GLint lightAttrib) {
+ s_starVertCount = 0;
+ s_starLightAttrib = lightAttrib;
+}
+
+static void android_stars_vertex(float x, float y, float z, float light) {
+ if (s_starVertCount >= (int)(sizeof(s_starVerts) / sizeof(s_starVerts[0]))) {
+ WARN("android_stars_vertex: dropping star past the %d-star limit", s_starVertCount);
+ return;
+ }
+ AndroidImmVertex &vert = s_starVerts[s_starVertCount++];
+ vert.x = x;
+ vert.y = y;
+ vert.z = z;
+ vert.light = light;
+}
+
+static void android_stars_end() {
+ if (s_starVertCount == 0)
+ return;
+ glEnableVertexAttribArray(0);
+ glEnableVertexAttribArray(s_starLightAttrib);
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_starVerts[0].x);
+ glVertexAttribPointer(s_starLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
+ &s_starVerts[0].light);
+ glDrawArrays(GL_POINTS, 0, s_starVertCount);
+ glDisableVertexAttribArray(0);
+ glDisableVertexAttribArray(s_starLightAttrib);
+}
+
// Shared by opengl_swap_and_restore()'s UI-overlay blit and
// android_composite_software_frame() below - draws an SDL_Surface as a
// fullscreen textured quad via gl4es. SDL's own renderer can't be used
@@ -537,42 +676,15 @@
set_blend_mode(blend);
- // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
- // immediate mode: glVertexAttrib*() is GLES2's *constant*-attribute
- // API - without an enabled vertex array it sets one value for the
- // whole draw call, not a fresh value per vertex. gl4es's immediate-mode
- // emulation only reproduces desktop GL's per-vertex-capture behavior
- // for attribute 0 (position, driven directly by glVertex3f), not for
- // custom attributes like this shader's texcoords/light.
- //
// V is flipped (1 at the bottom, 0 at the top): GL texture v=0
// addresses the first row of data passed to glTexImage2D, but uiRgba
// (an SDL surface) stores its rows top-down.
- struct QuadVertex {
- float x, y, z;
- float u, v;
- float light;
- };
- static const QuadVertex quadVerts[4] = {
- {1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f},
- {1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
- {-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f},
- {-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f},
- };
- glEnableVertexAttribArray(0);
- glEnableVertexAttribArray(tcAttrib);
- glEnableVertexAttribArray(lightAttrib);
- glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].x);
- glVertexAttribPointer(tcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].u);
- glVertexAttribPointer(lightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(QuadVertex),
- &quadVerts[0].light);
- glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
- // The engine's other rendering uses glBegin/glVertexAttrib/glVertex3f
- // immediate mode for attribute 0, which depends on it not having an
- // enabled array bound.
- glDisableVertexAttribArray(0);
- glDisableVertexAttribArray(tcAttrib);
- glDisableVertexAttribArray(lightAttrib);
+ android_imm_begin(tcAttrib, lightAttrib);
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
+ android_imm_end();
// 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
@@ -642,6 +754,16 @@
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+#ifdef __ANDROID__
+ // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
+ // immediate mode - see android_imm_begin()'s comment above for why.
+ android_imm_begin(tcAttrib, lightAttrib);
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
+ android_imm_end();
+#else
glBegin(GL_TRIANGLE_STRIP);
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
glVertexAttrib1f(lightAttrib, 1.0f);
@@ -656,6 +778,7 @@
glVertexAttrib1f(lightAttrib, 1.0f);
glVertex3f(-1.0f, 1.0f, 0.0f);
glEnd();
+#endif
// Finish drawing the 3d view
glFlush();
@@ -709,7 +832,10 @@
render_width = width;
render_height = height;
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
int lw, lh;
SDL_RenderGetLogicalSize(renderer, &lw, &lh);
@@ -729,7 +855,10 @@
}
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
// GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
// glTexImage2D() below (toCache.converted->pixels, a separately
@@ -771,7 +900,10 @@
DEBUG("Clearing OpenGL texture cache.");
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
for (auto iter = texturesByBitsPtr.begin(); iter != texturesByBitsPtr.end();) {
// don't free locked surfaces
@@ -896,10 +1028,16 @@
light = 1.0f - (clut - grd_screen->ltab) / 4096.0f;
}
+#ifdef __ANDROID__
+ (void)tcAttrib; // captured by android_imm_begin() instead - see its comment above
+ android_imm_vertex(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f,
+ vertex.uv.u / 256.0f, vertex.uv.v / 256.0f, light);
+#else
if (tcAttrib >= 0)
glVertexAttrib2f(tcAttrib, vertex.uv.u / 256.0, vertex.uv.v / 256.0);
glVertexAttrib1f(lightAttrib, light);
glVertex3f(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f);
+#endif
}
int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm) { return opengl_light_tmap(n, vp, bm); }
@@ -910,7 +1048,10 @@
return CLIP_ALL;
}
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
set_blend_mode(bm->flags & BMF_TRANS);
glUseProgram(textureShaderProgram.shaderProgram);
@@ -928,13 +1069,21 @@
glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, ViewMatrix);
glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, ProjectionMatrix);
+#ifdef __ANDROID__
+ android_imm_begin(tcAttrib, lightAttrib);
+#else
glBegin(GL_TRIANGLE_STRIP);
+#endif
draw_vertex(*(vp[1]), tcAttrib, lightAttrib);
draw_vertex(*(vp[0]), tcAttrib, lightAttrib);
draw_vertex(*(vp[2]), tcAttrib, lightAttrib);
if (n > 3)
draw_vertex(*(vp[3]), tcAttrib, lightAttrib);
+#ifdef __ANDROID__
+ android_imm_end();
+#else
glEnd();
+#endif
return CLIP_NONE;
}
@@ -949,7 +1098,10 @@
return CLIP_ALL;
}
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
set_blend_mode(bm->flags & BMF_TRANS);
glUseProgram(textureShaderProgram.shaderProgram);
@@ -976,6 +1128,14 @@
glUniform1i(textureShaderProgram.uniMutant, (bm->type == BMT_TLUC8) || (bm->flags == BMF_TLUC8));
+#ifdef __ANDROID__
+ android_imm_begin(tcAttrib, lightAttrib);
+ android_imm_vertex(convx(vpl[1]->x), convy(vpl[1]->y), 0.0f, 1.0f, 0.0f, light);
+ android_imm_vertex(convx(vpl[0]->x), convy(vpl[0]->y), 0.0f, 0.0f, 0.0f, light);
+ android_imm_vertex(convx(vpl[2]->x), convy(vpl[2]->y), 0.0f, 1.0f, 1.0f, light);
+ android_imm_vertex(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f, 0.0f, 1.0f, light);
+ android_imm_end();
+#else
glBegin(GL_TRIANGLE_STRIP);
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
glVertexAttrib1f(lightAttrib, light);
@@ -990,6 +1150,7 @@
glVertexAttrib1f(lightAttrib, light);
glVertex3f(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f);
glEnd();
+#endif
glUniform1i(textureShaderProgram.uniMutant, false);
@@ -1007,7 +1168,10 @@
return CLIP_ALL;
}
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
glUseProgram(colorShaderProgram.shaderProgram);
glUniformMatrix4fv(colorShaderProgram.uniView, 1, false, ViewMatrix);
@@ -1064,7 +1228,11 @@
GLint lightAttrib = colorShaderProgram.lightAttrib;
long a = 0, b = n_verts - 1;
+#ifdef __ANDROID__
+ android_imm_begin(-1, lightAttrib);
+#else
glBegin(GL_TRIANGLE_STRIP);
+#endif
while (true) {
draw_vertex(*(p[a]), -1, lightAttrib);
if (a++ == b)
@@ -1073,7 +1241,11 @@
if (b-- == a)
break;
}
+#ifdef __ANDROID__
+ android_imm_end();
+#else
glEnd();
+#endif
return CLIP_NONE;
}
@@ -1085,7 +1257,10 @@
}
void opengl_begin_stars() {
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
+#endif
// Bind the star shader before setting the point size: on Android,
// questshock_glPointSize() (see the __ANDROID__ block above) needs
@@ -1108,11 +1283,19 @@
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
+#ifdef __ANDROID__
+ android_stars_begin(starShaderProgram.lightAttrib);
+#else
glBegin(GL_POINTS);
+#endif
}
void opengl_end_stars() {
+#ifdef __ANDROID__
+ android_stars_end();
+#else
glEnd();
+#endif
// Turn off the stencil test and blending
opengl_set_stencil(0x00);
@@ -1120,9 +1303,11 @@
}
int opengl_draw_star(fix star_x, fix star_y, int c, bool anti_alias) {
+#ifndef __ANDROID__
+ // See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
-
GLint lightAttrib = starShaderProgram.lightAttrib;
+#endif
float x = fix_float(star_x);
float y = fix_float(star_y);
@@ -1142,8 +1327,12 @@
int color = (std_color_base + std_color_range - 1 - c);
color = (255 * color) / (std_color_range + 1);
+#ifdef __ANDROID__
+ android_stars_vertex(x, y, -0.25f, color / 255.0f);
+#else
glVertexAttrib1f(lightAttrib, color / 255.0f);
glVertex3f(x, y, -0.25f);
+#endif
return CLIP_NONE;
}
@@ -0,0 +1,45 @@
--- a/src/GameSrc/frsetup.c
+++ b/src/GameSrc/frsetup.c
@@ -635,7 +635,27 @@
uchar old_cam_type;
int detail;
+#ifdef __ANDROID__
+ // should_opengl_swap() (unlike use_opengl(), see its own
+ // FR_PICKUPM_MASK|FR_HACKCAM_MASK exclusion just below and the
+ // matching one used for the STEREO_SUPPORT path in fr_send_view()
+ // below) doesn't know which _fr context is currently being prepared -
+ // it's a frame-level check, also used by Shock.c's SDLDraw() to pick
+ // the frame's overall compositing path. render_hack_cameras() (see
+ // render.c) calls fr_rend() - and so this function - a second time per
+ // real frame for each active hack-camera monitor, rendering into a
+ // private software (FR_DOUBLEB_MASK) canvas rather than the actual
+ // GL-backed screen. Without this exclusion, opengl_start_frame() runs
+ // again mid-frame for that render even though its own polygon draw
+ // calls correctly stay on the software path (use_opengl() already
+ // excludes them) - re-entering the OpenXR swapchain/framebuffer setup
+ // a second time before the first call's frame has ended. Not known to
+ // be exercised on desktop (no OpenXR swapchain there), so this fix is
+ // kept Android-only rather than changing should_opengl_swap() itself.
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
+#else
if(should_opengl_swap()) {
+#endif
opengl_start_frame();
}
@@ -823,7 +843,14 @@
g3_end_frame();
+#ifdef __ANDROID__
+ // Mirrors the same fix in fr_start_view() above - see its comment.
+ // opengl_start_frame()/opengl_end_frame() must stay paired per real
+ // GL frame, so this exclusion has to match that one exactly.
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
+#else
if(should_opengl_swap()) {
+#endif
opengl_end_frame();
}
@@ -0,0 +1,34 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -455,10 +455,30 @@
#ifndef __ANDROID__
// See opengl_resize()'s comment above.
SDL_GL_MakeCurrent(window, context);
-#endif
// Done rendering to the frame buffer, reset back to normal
BindFrameBuffer(nullptr);
+#else
+ // BindFrameBuffer(nullptr) (glBindFramebuffer/glBindRenderbuffer to id
+ // 0) means "the real window surface" - on desktop that's SDL's actual
+ // GL window, but this app's real presentation target is never that
+ // SDLSurface's own EGL surface at all - it's the OpenXR swapchain's
+ // per-image FBOs, set up separately in xr_swapchain.c. Whatever
+ // framebuffer/renderbuffer 0 actually resolves to for gl4es in this
+ // configuration, binding it here reliably left gl4es reporting a
+ // persistent GL_INVALID_OPERATION (1282) - confirmed by bisecting
+ // through this whole render path with per-call checkpoints, all clean
+ // right up until "after opengl_end_frame", the one immediately after
+ // this exact call.
+ //
+ // The bind is also functionally dead on Android regardless of that:
+ // by the time anything next draws, Shock.c's SDLDraw() has already
+ // run xr_frame_begin() -> xr_swapchain_acquire(), which rebinds to
+ // that frame's real swapchain FBO before opengl_swap_and_restore()
+ // (the only thing that draws before the next opengl_start_frame()'s
+ // own BindFrameBuffer(&backupBuffer)) runs - so skipping it changes
+ // nothing about what actually gets rendered where.
+#endif
palette_dirty = false;
}
@@ -0,0 +1,54 @@
--- a/src/MacSrc/Shock.c
+++ b/src/MacSrc/Shock.c
@@ -31,6 +31,7 @@
#include <SDL.h>
#ifdef __ANDROID__
+#include "qs_config.h"
#include "xr_session.h"
#endif
@@ -109,6 +110,15 @@
LoadHotkeyKeybinds();
LoadMoveKeybinds();
+#ifdef __ANDROID__
+ // Persistent, human-editable JSON config (questshock.config.json) - see
+ // qs_config.h/CONFIG.md. After prefs.txt is loaded (above) so this file
+ // can override it, before init_all() below so there's no live-vs-
+ // restart distinction to worry about yet - every field just takes full
+ // effect as part of this same startup.
+ qs_config_load_startup();
+#endif
+
// Process some startup arguments
bool show_splash = !CheckArgument("-nosplash");
--- a/src/Libraries/INPUT/Source/sdl_events.c
+++ b/src/Libraries/INPUT/Source/sdl_events.c
@@ -29,6 +29,10 @@
#include <SDL.h>
#include <OpenGL.h>
+#ifdef __ANDROID__
+#include "qs_config.h"
+#endif
+
extern SDL_Window *window;
extern SDL_Renderer *renderer;
@@ -481,6 +485,14 @@
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
+#ifdef __ANDROID__
+ // Custom event types qs_config_request_save()/_load() enqueue from
+ // the "Save Configuration"/"Load Configuration" menu buttons (see
+ // qs_config.h) - handled here, on this thread, rather than directly
+ // from those JNI calls (which run on Android's UI thread).
+ if (qs_config_handle_event(&ev))
+ continue;
+#endif
switch (ev.type) {
case SDL_QUIT:
// a bit hacky at this place, but this would allow exiting the game via the window's [x] button
@@ -0,0 +1,30 @@
--- a/src/GameSrc/mainloop.c
+++ b/src/GameSrc/mainloop.c
@@ -57,6 +57,10 @@
#include "tools.h"
#include "wrapper.h"
+#ifdef __ANDROID__
+#include "qs_config.h"
+#endif
+
// how is the game doing, anyway, set to true at end of time
uchar cit_success = FALSE;
@@ -103,6 +107,16 @@
_last_mode = *cmode;
(*exit_modes[_last_mode])();
*cmode = _new_mode;
+#ifdef __ANDROID__
+ // Notifies qs_config.c of every loop-mode transition so it can pick
+ // between the "Initial Screen" and "Game Screen" virtual-screen
+ // presets - see qs_config.h/CONFIG.md. Called synchronously, directly,
+ // on this thread (mainloop()'s own) - unlike the UI-thread-triggered
+ // config actions elsewhere in qs_config.c, this doesn't need the
+ // cross-thread SDL-event dance since it already originates on the
+ // right thread.
+ qs_screen_profile_notify_loop_mode(*cmode);
+#endif
_static_change = 0;
if (*cmode >= 0)
(*enter_modes[*cmode])();
@@ -0,0 +1,42 @@
--- a/src/GameSrc/cutsloop.c
+++ b/src/GameSrc/cutsloop.c
@@ -292,11 +292,38 @@
+#ifdef __ANDROID__
+// The intro (cutscene 0) ships in one file per language - localized
+// voice-over - each holding the same resource id (cutscene_anims[0]), so
+// opening the current language's file instead is all it takes. The death
+// and ending cutscenes only have one version.
+static char *intro_files_by_language[3] =
+{
+ "res/data/svgaintr.res", // English
+ "res/data/svfrintr.res", // French
+ "res/data/svgeintr.res" // German
+};
+
+static int open_cutscene_file(int id)
+{
+ if (id == 0 && which_lang > 0 && which_lang < 3)
+ {
+ int handle = ResOpenFile(intro_files_by_language[(int)which_lang]);
+ if (handle > 0)
+ return handle;
+ // Not installed for this language - fall back to English below.
+ }
+ return ResOpenFile(cutscene_files[id]);
+}
+#else
+#define open_cutscene_file(id) ResOpenFile(cutscene_files[id])
+#endif
+
short play_cutscene(int id, bool show_credits)
{
MacTuneKillCurrentTheme();
- cutscene_filehandle = ResOpenFile(cutscene_files[id]);
+ cutscene_filehandle = open_cutscene_file(id);
if (cutscene_filehandle <= 0) {
// If we failed to play the cutscene, go to setup / credits.
_new_mode = SETUP_LOOP;
@@ -0,0 +1,13 @@
--- a/src/GameSrc/mouselook.c
+++ b/src/GameSrc/mouselook.c
@@ -121,3 +121,10 @@
get_mouselook_vel(&mvelx, &mvely);
}
}
+
+#ifdef __ANDROID__
+// Whether the player is currently in cyberspace; read by the Android
+// controller input code (android/app/src/main/cpp/xr_input.c) to switch
+// between the normal and cyberspace action sets.
+int qs_in_cyberspace(void) { return global_fullmap != NULL && global_fullmap->cyber; }
+#endif
@@ -0,0 +1,110 @@
--- a/src/GameSrc/movekeys.c
+++ b/src/GameSrc/movekeys.c
@@ -44,6 +44,12 @@
extern bool gKeypadOverride;
uchar parse_motion_key(ushort code, short *cnum, short *cval);
+#ifdef __ANDROID__
+// Set every frame by android/app/src/main/cpp/xr_input.c.
+float qs_cyber_pitch_scale = 1.0f;
+float qs_cyber_bank_scale = 1.0f;
+#endif
+
uchar parse_motion_key_cyber(ushort code, short *cnum, short *cval);
void init_motion_polling(void);
@@ -173,6 +179,20 @@
*cnum = -1;
*cval = 0;
+#ifdef __ANDROID__
+ // Pitch and bank strength, scaled by the controller thumbstick's
+ // deflection (see android/app/src/main/cpp/xr_input.c); 1.0 for a key.
+ short pitchMax = (short)(CONTROL_MAX_VAL * qs_cyber_pitch_scale + 0.5f);
+ short bankMax = (short)(CONTROL_MAX_VAL * qs_cyber_bank_scale + 0.5f);
+ if (pitchMax < 1)
+ pitchMax = 1;
+ if (bankMax < 1)
+ bankMax = 1;
+#else
+ short pitchMax = CONTROL_MAX_VAL;
+ short bankMax = CONTROL_MAX_VAL;
+#endif
+
while (MoveCyberKeybinds[i].code != 255) {
if (code == MoveCyberKeybinds[i].code) {
move = MoveCyberKeybinds[i].move;
@@ -189,22 +209,22 @@
case M_CLIMB:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
break;
case M_BANKLEFT:
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
case M_BANKRIGHT:
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVE:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
break;
case M_ROLLRIGHT:
@@ -219,38 +239,38 @@
case M_CLIMBLEFT:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
case M_CLIMBRIGHT:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVERIGHT:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVELEFT:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
default:
// Unhandled movement. What I gonna do?
@@ -0,0 +1,74 @@
--- a/src/GameSrc/hkeyfunc.c
+++ b/src/GameSrc/hkeyfunc.c
@@ -1465,3 +1465,71 @@
#pragma enable_message(202)
#endif // NOT_YET
+
+#ifdef __ANDROID__
+// Tops up parts of the player's state; android/app/src/main/cpp/qs_config.c
+// calls it once per frame with the "unlimited" cheats that are switched on.
+// `what` is a bitmask: 1 health, 2 energy, 4 ammo, 8 items (the same values as
+// QS_CHEAT_MASK_* in qs_config.c). Only conventional guns use ammo: beam and
+// melee weapons keep heat in the same field, so they are left alone (beams draw
+// on energy). Spare ammo clips, grenades and drugs are only refilled for kinds
+// the player already holds, so the cheat never creates items.
+void qs_cheat_refill(int what) {
+ const int kAmmoFloor = 99;
+ const int kItemFloor = 99;
+ uchar vitalsChanged = FALSE;
+ uchar inventoryChanged = FALSE;
+
+ if (player_struct.dead)
+ return;
+ if ((what & 1) && player_struct.hit_points < PLAYER_MAX_HP) {
+ player_struct.hit_points = PLAYER_MAX_HP;
+ vitalsChanged = TRUE;
+ }
+ if ((what & 1) && player_struct.cspace_hp < PLAYER_MAX_HP) {
+ player_struct.cspace_hp = PLAYER_MAX_HP;
+ vitalsChanged = TRUE;
+ }
+ if ((what & 2) && player_struct.energy < 255) {
+ player_struct.energy = 255;
+ vitalsChanged = TRUE;
+ }
+ if (vitalsChanged)
+ chg_set_flg(VITALS_UPDATE);
+
+ if (what & 4) {
+ for (int i = 0; i < NUM_WEAPON_SLOTS; i++) {
+ weapon_slot *slot = &player_struct.weapons[i];
+ if (slot->type != EMPTY_WEAPON_SLOT && slot->type <= GUN_SUBCLASS_SPECIAL &&
+ slot->ammo < kAmmoFloor) {
+ slot->ammo = kAmmoFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ for (int i = 0; i < NUM_AMMO_TYPES; i++) {
+ if (player_struct.cartridges[i] > 0 && player_struct.cartridges[i] < kItemFloor) {
+ player_struct.cartridges[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ }
+ if (what & 8) {
+ for (int i = 0; i < NUM_GRENADEZ; i++) {
+ if (player_struct.grenades[i] > 0 && player_struct.grenades[i] < kItemFloor) {
+ player_struct.grenades[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ for (int i = 0; i < NUM_DRUGZ; i++) {
+ if (player_struct.drugs[i] > 0 && player_struct.drugs[i] < kItemFloor) {
+ player_struct.drugs[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ }
+ if (inventoryChanged) {
+ chg_set_flg(INVENTORY_UPDATE);
+ mfd_force_update();
+ }
+}
+#endif
@@ -0,0 +1,24 @@
--- a/src/GameSrc/setup.c 2026-09-20 16:04:46.197310679 +0200
+++ b/src/GameSrc/setup.c 2026-09-20 16:05:08.400330327 +0200
@@ -58,6 +58,9 @@
#include "gamestrn.h"
#include "mainloop.h"
+#ifdef __ANDROID__
+#include "qs_splash.h"
+#endif
#include "tools.h"
#include "input.h"
#include "game_screen.h"
@@ -1300,6 +1303,11 @@
if (!show_splash)
return;
+#ifdef __ANDROID__
+ // The QuestShock title, before the engine's own splash screens.
+ qs_splash_show();
+#endif
+
// Need to load the splash palette file
INFO("Loading splshpal.res");
+28
View File
@@ -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."
@@ -0,0 +1,47 @@
diff -ru a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt 2026-07-31 13:25:14.739711912 +0200
+++ b/CMakeLists.txt 2026-07-31 13:25:29.283619378 +0200
@@ -2,11 +2,22 @@
project(gl4es LANGUAGES C)
+# questshock: on Android this project is add_subdirectory()'d straight into
+# the engine's own CMake configure (see android/engine-patches/
+# 02-android-opengl-es.patch), not built standalone - CMAKE_SOURCE_DIR then
+# points at the *outer* project's root, not here, so writing there would
+# scatter build output outside this project entirely. Leaving these unset
+# for Android just falls back to CMake's normal per-target build-tree
+# output, which AGP's own native-build packaging finds regardless of path;
+# link_directories() is also unneeded there since the GL/EGL targets link
+# each other by CMake target name, not by search path.
+if(NOT ANDROID)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
link_directories(${CMAKE_LIBRARY_OUTPUT_DIRECTORY})
+endif()
option(PANDORA "Set to ON if targeting an OpenPandora device" ${PANDORA})
option(PYRA "Set to ON if targeting an Dragonbox Pyra device" ${PYRA})
diff -ru a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt 2026-07-31 13:25:14.747711861 +0200
+++ b/src/CMakeLists.txt 2026-07-31 13:25:44.043526054 +0200
@@ -220,7 +220,16 @@
target_link_libraries(GL ${log-lib})
endif()
- if(CMAKE_SHARED_LIBRARY_SUFFIX MATCHES ".so")
+ # questshock: on Android, AGP's native-build packaging only bundles a
+ # jniLibs .so whose *filename* literally ends in ".so" (and the dynamic
+ # linker resolves NEEDED entries by embedded SONAME, which CMake derives
+ # from this same filename) - versioning it to "libGL.so.1" like the
+ # desktop build does would need a separate patchelf rename step, which
+ # only build-image's Docker environment could guarantee has this tool.
+ # Skipping the override on Android instead so CMake's own default
+ # SHARED library naming (plain "libGL.so", correct SONAME included)
+ # already comes out right with no extra step.
+ if(CMAKE_SHARED_LIBRARY_SUFFIX MATCHES ".so" AND NOT ANDROID)
set_target_properties(GL PROPERTIES SUFFIX ".so.1")
endif()
install(TARGETS GL
+12
View File
@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: ptitSeb
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: paypal.me/0ptitSeb
+11
View File
@@ -0,0 +1,11 @@
.gdb_history
.vscode/
CMakeFiles/
lib/
Makefile
CMakeCache.txt
CTestTestfile.cmake
cmake_install.cmake
spec/yml/gles-1.1-full.yml
/build/
/tests/*.png
+20
View File
@@ -0,0 +1,20 @@
language: c
sudo: false
dist: buster
compiler:
- gcc
#Build steps
before_script:
- mkdir build
- cd build
- cmake .. -DODROID=1
script:
- make
#after_script:
# - sudo apt-get install apitrace-gl-frontend=5\* imagemagick xvfb -y
# - cd ../tests
# - xvfb-run ./tests.sh ../lib
+104
View File
@@ -0,0 +1,104 @@
LOCAL_PATH := $(call my-dir)
###########################
#
# GL static library
#
###########################
include $(CLEAR_VARS)
LOCAL_MODULE := GL
LOCAL_C_INCLUDES := $(LOCAL_PATH)/include
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_C_INCLUDES) -DBCMHOST
LOCAL_SRC_FILES := \
src/gl/arbconverter.c \
src/gl/arbgenerator.c \
src/gl/arbhelper.c \
src/gl/arbparser.c \
src/gl/array.c \
src/gl/blend.c \
src/gl/blit.c \
src/gl/buffers.c \
src/gl/build_info.c \
src/gl/debug.c \
src/gl/decompress.c \
src/gl/depth.c \
src/gl/directstate.c \
src/gl/drawing.c \
src/gl/enable.c \
src/gl/envvars.c \
src/gl/eval.c \
src/gl/face.c \
src/gl/fog.c \
src/gl/fpe.c \
src/gl/fpe_cache.c \
src/gl/fpe_shader.c \
src/gl/framebuffers.c \
src/gl/gl_lookup.c \
src/gl/getter.c \
src/gl/gl4es.c \
src/gl/glstate.c \
src/gl/hint.c \
src/gl/init.c \
src/gl/light.c \
src/gl/line.c \
src/gl/list.c \
src/gl/listdraw.c \
src/gl/listrl.c \
src/gl/loader.c \
src/gl/logs.c \
src/gl/matrix.c \
src/gl/matvec.c \
src/gl/oldprogram.c \
src/gl/pixel.c \
src/gl/planes.c \
src/gl/pointsprite.c \
src/gl/preproc.c \
src/gl/program.c \
src/gl/queries.c \
src/gl/raster.c \
src/gl/render.c \
src/gl/samplers.c \
src/gl/shader.c \
src/gl/shaderconv.c \
src/gl/shader_hacks.c \
src/gl/stack.c \
src/gl/stencil.c \
src/gl/string_utils.c \
src/gl/stubs.c \
src/gl/texenv.c \
src/gl/texgen.c \
src/gl/texture.c \
src/gl/texture_compressed.c \
src/gl/texture_params.c \
src/gl/texture_read.c \
src/gl/texture_3d.c \
src/gl/uniform.c \
src/gl/vertexattrib.c \
src/gl/wrap/gl4eswraps.c \
src/gl/wrap/gles.c \
src/gl/wrap/glstub.c \
src/gl/math/matheval.c \
src/glx/hardext.c \
src/glx/glx.c \
src/glx/lookup.c \
src/glx/gbm.c \
src/glx/streaming.c \
LOCAL_CFLAGS += -g -std=gnu99 -funwind-tables -O3 -fvisibility=hidden -include include/android_debug.h
LOCAL_CFLAGS += -DNOX11
LOCAL_CFLAGS += -DNO_GBM
#LOCAL_CFLAGS += -DNO_INIT_CONSTRUCTOR
LOCAL_CFLAGS += -DDEFAULT_ES=2
//TODO: maybe temporary?
LOCAL_CFLAGS += -Wno-typedef-redefinition -Wno-dangling-else
LOCAL_LDLIBS := -llog
#building as a static lib
LOCAL_CFLAGS += -DSTATICLIB
include $(BUILD_STATIC_LIBRARY)
+184
View File
@@ -0,0 +1,184 @@
Version history
----
##### v1.1.6
* Improve glGetError() handling
* Improve LIBGL_FB=3 handling
* Added some ShaderHacks
* Better Renderbuffer handling
* Fixed many issues with VAO and VBO
* Improved ARB shader support
* optimized some format conversion
* Improve mipmap handling
* Improve Android support
* Added sampler handling
* Improve texture format handling
* some fixes to a few matrix computation
* Added support for a new platform: Windows (thx to @yjh-styx)
* Added some more extension to GLES2 backend
##### v1.1.4
* Fixed some regression, and foobillard++ is working fine now
* Set default GL version to 2.1 (instead of 2.0) for GLES2.0 backend
* Added ARB_vertex_program and ARB_fragment_program
* Improved fpe with more compatibility fixes
* Improved Handling of DXTc textures
* Improve handling of partially mipmap'd textures
* Added PYRA Profile
* Added support for Texture LOD access in Fragment Shaders
* Increased maximum Texture Unit support to 16 (from 8)
* Added support for program without Vertex Shader
* Some fixes for program that switch GLX Context frequently.
* Fixes and improvements in fpe_shaders (less array access)
* Added support for Clipping Plane in custom shaders (help OpenMW water reflection)
* Added support for multiple FBO attachement (if hardware support it)
##### v1.1.2
* Improved a bit the merger (wich merge subsequent `glBegin(...)`/`glEnd()`) efficiency
* Removed LIBGL_BEGINEND=2 (that was not working correctly, and it complexify the code too much)
* Added some Direct Access function (from EXT_direct_access)
* Fixes and Improvements on depth (and depth_Stencil) Texture handling
* Handling of GL_BGRA color size in VA (for HumandRessourceMachine)
* Some fixes to fpe_shader (for Neverwinter Night)
* Rework of Header structure (from @rajdakin)
* Better test, using "make test" (from @rajdakin, still using apitrace for replay)
* Added PSA: Precompiled Shader Archive, to store (and fast retrieve) FPE shader (and avoid some pauses when complex FPE shaders are computed)
* Numerous optimization and changes in Batch mode.
* Fixes to GL_BGRA handling
* Improvement in texture format handling, to limit number of conversions
* Added handling of VBO. Real VBO are used (in GLES2+ only) for GL_ARRAY_BUFFER and GL_ELEMENT_ARRAY_BUFFER
* Added VBO when processing glList (only in GLES2+)
* Added a few more GL3.x functions (like glTexStorage). No real GL3.0 support yet
* Can now use GBM/DRM, using LIBGL_FB=4. Still WIP, but now it start to work (thanks to @icecream95)
* Preliminary Emscripten support (thanks to @CecilHarvey)
* AmigaOS4 support is now live (thanks to @kas1e)
* Some fixes to texture handling, when uploading (or modifying) a texture in a multi-texture context
* Small optimization some of the FPE Generated program (many fragment shaders that do texturing)
* Added support to glGetProgramBinary extension
* GL4ES has a logo now :) !
* AmigaOS4 is now fully supported! And SDK (with libs and samples) is available on os4depot
* Emscripten is now supported! A first example of a game using gl4es in a web build can be found [here](http://ptitseb.github.io/stuntcarremake/) with [Stunt Car Remake](https://github.com/ptitSeb/stuntcarremake)
##### v1.1.0
* Default backend is now GLES2 (but not on Pandora, still GLES1.1 for compatibility reasons)
* Added LIBGL_SHADERNOGLES to remove the GLES part of shaders (if any).
* Various RPi improvements, mainly in the context creation.
* Various AmigaOS4 improvement and workaround (but still in a beta state)
* Various improvement in GLX function, with a more accurate way to emulate GLXFBConfig
* Improved the way eglSurface are created in glx.c, to avoid try to create 2 on the same window (EGL doesn't allow that)
* Added LIBGL_GLXRECYCLE to not delete eglSurface and recycle them
* Added tracking of Framebuffers Object and Renderbuffers Objects
* Added (real) support for Float and Half-float Texture (including has attachement to FBO, emulating it if not supported in Hardware)
* Added support for Depth Stencil texture when attached to an FBO
* Added LIBGL_FBO=WxH for all platform
* Fixed some issue when resized textures attached to an FBO
* Added LIBGL_NODEPTHTEX to avoid using Depth Texture when available (using renderbuffer can be faster)
* Added support for Depth Texture when attached to an FBO (if supported by Hardware)
* Fixes some isue with blitting of FBO when size of Main Framebuffer changed
* Added option LIBGL_LOGSHADERERROR to get Shader compiler log and error
* Added support for (emulated) Hardware Instancing
* Added support for GL_ARB_draw_elements_base_vertex
##### v1.0.8
* Fixes and improvments to avoid unnecessary GLES state changes
* Fixes some memory issues with glBitmap
* Fixes to FPE (when using multitexture and GL_COMBINE)
* Added some TexEnv extension for GLES2 backend
* Fixes to Batch / Merger on GL_POLYGON primitives
* Fixes to LineStipple
* Pandora only: Fixed use of Texture Streaming on GLES2 backend
##### v1.0.6
* Factorised "Blit" function, and implemented `glBlitFramebuffer`
* Optimized `glBitmap`
* Added (limited) direct support to `GL_UNSIGNED_INT` for `glDrawElements` for hardware that support it
* Improved (a lot) `glBegin`/`glEnd` merger
* Added Anisotropic filtering support, for hardware that support it.
* Changed `LIBGL_BATCH`. It will now try to merge small (parametrable) subsequent `glDrawXXXX`
* Changed (simplified) the way texture "0" is handled
* Improvement to `glDrawArrays` and `glDrawElement`, with less copy of data
* WIP AmigaOS4 support (and BigEndian architecture)
* Improved NPOT support for "Limited NPOT" hardware
* Lots of FPE fixes
* Improve the way multi glX Context are handled
* Added basic pre-proc (only handle comments for now)
* Small optimization on when using `glDrawArrays` with GL_QUADS
* Improvement to GL_RENDER
* Improvement to line stipple
* Improvement to glPolygonMode(GL_LINE)
##### v1.0.4
* FPE is now usable. Most function are implemented
* ShaderConv in now usable. Basic GL 2 shader are supported
* Refactored Texture cache and handling, for better Tex1D/Tex3D/TexRectangle handling
##### v1.0.2
* Added GLES2 backend infrastructure
* Begin GLES2 backend
* Infrastructure for FPE (Fixed Pipeline Emulator)
* Basic and Crude Shader convertor
* Added blit function for GLES2 backend
* Added some OpenGL Builtin VAs and Uniforms
##### 1.0.0
* Removed old ES2 defines (ES2 will be dynamic later)
##### 0.9.8
* Added TravisCI build on github
* Added some optimization when to discard call to glBindTexture if useless (same texture) between 2 glBegin/glEnd blocks
* Stubbed glGet with GL_DRAW_BUFFER
* Improvement to Raster operations
* Factorised Blit function (only 1 function for that now)
* Tracking ShadeModel
* Tracking TexEnv
##### 0.9.7
* Tracking Clip Planes
* Refactor Blitting function, and use glDrawTex extension if present
* Restructured README and split in several files
* Some improvments and fixes to LIBGL_BEGINEND=2 mode
* Some improvments to some LIBGL_SHRINK mode
* Proper support for DOT3 extension
* Some fixes to Read/Draw Buffer handling
* Some fix with the PixMap glX context creation
##### 0.9.6
* Some fixes in GL_TEXTURE_RECTANGLE_ARB handling
* Some other fixes in texture handling (unpack and glList related)
* Some fix with the PBuffer glX context creation
* Tracking of glFog
* Exposed glBlendEquation if supported
* New LIBGL_AVOID16BITS parameter to prefer 32bits texture (useful on ODroid)
* Some optimisations in texture conversion
##### 0.9.5
* Added some optimizations for sequential glBegin/glEnd blocks, with a switch to control them
* Fixed many issue with Lights introduced with 0.9.4
* Fixed Android build introduced with 0.9.4
##### 0.9.4
* Fixed some extended functions (like glBlendFuncSeparate) not working inside list (fixing some issues with Batch mode)
* Added back GL_TEXTURE_RECTANGLE_ARB handling (and using npot texture, even limited, if available)
* Added tracking of Lights and Materials
* Fixed (Added in fact) support for Multisampling on the GLX Context creation (a bit hacky, but seems to works)
* Added LIBGL_NODOWNSAMPLING and associated Hint
* Try to implement some caching on VAO to avoid some memcpy in renderlist (with a way to disable it just in case)
##### 0.9.3
* Added support for Cube Mapping (with hardware support)
* Improved Texture state tracking
* Added LIBGL_NOTEXMAT env. var. switch for Texture Matrix handling
* Added GL_EXT_vertex_array_bgra (and NEONinzed some loop)
* Finished GL_EXT_direct_state_access extension
* Mangled glX function (to be able to use apitrace to capture GL frames)
* Return some values in glXQueryServerString, coherent with glXGetClientString
##### 0.9.2
* All matrix are tracked now
* Texture Matrix are 100% handled by gl4es. GLES Hardware keep an Identity matrix (TexCoord are transformed if needed). This allows a better handling of NPOT texture on hardware that doesn't support Full NPOT (fixed movies being horizontally shifted in openmw with LIBGL_NPOT=1 for example)
##### 0.9.1
* Added gl4es specifics glHint capabilities. If the extension GL_GL4ES_hint is present, than a few Hint are accessible. Look in include/gl4eshint.h for the list.
##### 0.9.0
* New name: gl4es
+300
View File
@@ -0,0 +1,300 @@
cmake_minimum_required(VERSION 2.8.12)
project(gl4es LANGUAGES C)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
link_directories(${CMAKE_LIBRARY_OUTPUT_DIRECTORY})
option(PANDORA "Set to ON if targeting an OpenPandora device" ${PANDORA})
option(PYRA "Set to ON if targeting an Dragonbox Pyra device" ${PYRA})
option(BCMHOST "Set to ON if targeting an RPi(2) device" ${BCMHOST})
option(ODROID "Set to ON if targeting an ODroid device" ${ODROID})
option(GOA_CLONE "Set to ON if targeting GO Advance clones, like RG351p/v, Gameforce Chi, RGB10..." ${GOA_CLONE})
option(ANDROID "Set to ON if targeting an Android device" ${ANDROID})
option(CHIP "Set to ON if targeting an C.H.I.P. device" ${CHIP})
option(AMIGAOS4 "Set to ON if targeting an AmigaOS4/Warp3D platform (activate NOEGL and NOX11)" ${AMIGAOS4})
option(NOX11 "Set to ON to not use X11 (creation of context has to be done outside gl4es)" ${NOX11})
option(NOEGL "Set to ON to not use EGL (all functions are taken in GLES library)" ${NOEGL})
option(STATICLIB "Set to ON to build a static version of gl4es" ${STATICLIB})
option(GBM "Set to ON to not build GBM interface" ${GBM})
option(USE_CCACHE "Set to ON to use ccache if present in the system" ${USE_CCACHE})
option(USE_CLOCK "Set to ON to use clock_gettime instead of gttimeofday for LIBGL_FPS" ${USE_CLOCK})
option(NO_LOADER "disable library loader (useful for static library with NOEGL, NOX11, use include/gl4esinit.h)" ${NO_LOADER})
option(NO_INIT_CONSTRUCTOR "disable automatic initialization (useful for static library, use include/gl4esinit.h)" ${NO_INIT_CONSTRUCTOR})
option(USE_ANDROID_LOG "Set to ON to use Android log instead of stdio" ${USE_ANDROID_LOG})
option(EGL_WRAPPER "Set to ON to build EGL wrapper" ${EGL_WRAPPER})
option(GLX_STUBS "Set to ON to build GLX function stubs" ${GLX_STUBS})
include(CheckSymbolExists)
check_symbol_exists(backtrace "execinfo.h" HAS_BACKTRACE)
if (HAS_BACKTRACE)
add_definitions(-DHAS_BACKTRACE)
endif()
if(CMAKE_SYSTEM_NAME MATCHES "Windows" OR CMAKE_SYSTEM_NAME MATCHES "MSYS")
set(NOX11 ON)
set(NO_GBM ON)
set(WIN32_PLATFORM ON)
if(CMAKE_C_COMPILER_ID MATCHES "MSVC" OR "x${CMAKE_C_SIMULATE_ID}" STREQUAL "xMSVC")
set(WIN32_MSVC ON) #msvc or icl or clang-cl
endif()
endif()
# Pandora
if(PANDORA)
add_definitions(-DPANDORA)
add_definitions(-DTEXSTREAM)
add_definitions(-mcpu=cortex-a8 -mfpu=neon -mfloat-abi=softfp -ftree-vectorize -fsingle-precision-constant -ffast-math)
set(NO_GBM ON)
endif()
if((NOT GBM) OR PANDORA)
set(NO_GBM ON)
else()
set(NO_GBM OFF)
endif()
if(NOT DEFAULT_ES)
if(PANDORA)
set(DEFAULT_ES 1)
else()
set(DEFAULT_ES 2)
endif()
endif()
if(STATICLIB)
add_definitions(-DSTATICLIB)
endif(STATICLIB)
# Pyra
if(PYRA)
add_definitions(-DPYRA)
set(USE_CLOCK ON)
#add_definitions(-DTEXSTREAM)
add_definitions(-mcpu=cortex-a15 -mfpu=neon-vfpv4 -mfloat-abi=hard -ftree-vectorize -fsingle-precision-constant -ffast-math)
set(NO_GBM ON)
endif()
# Raspberry PI
if(BCMHOST)
include_directories(/opt/vc/include /opt/vc/include/interface/vcos/pthreads /opt/vc/include/interface/vmcs_host/linux)
link_directories(/opt/vc/lib)
add_definitions(-DBCMHOST)
endif()
# ODROID
if(ODROID)
add_definitions(-DODROID)
endif()
# GOA_CLONE
if(GOA_CLONE)
add_definitions(-DGOA_CLONE)
add_definitions(-mcpu=cortex-a35 -mfpu=neon-vfpv3 -march=armv8-a+crc+simd+crypto -mfloat-abi=hard -ftree-vectorize -fsingle-precision-constant -ffast-math)
set(EGL_WRAPPER ON)
set(GLX_STUBS ON)
endif()
# Android
if(ANDROID)
add_definitions(-DANDROID)
add_definitions(-DNOX11 -DNO_GBM -DDEFAULT_ES=2)
endif()
if(USE_ANDROID_LOG)
add_definitions(-DUSE_ANDROID_LOG)
find_library(log-lib log)
endif()
#PocketCHIP
if(CHIP)
add_definitions(-DCHIP)
add_definitions(-mcpu=cortex-a8 -mfpu=neon -mfloat-abi=hard -ftree-vectorize -fsingle-precision-constant -ffast-math)
endif()
# AmigaOS4
if(AMIGAOS4)
set(CMAKE_C_COMPILER "ppc-amigaos-gcc")
set(CMAKE_CXX_COMPILER "ppc-amigaos-g++")
set(CMAKE_LINKER "ppc-amigaos-ld")
set(CMAKE_AR "ppc-amigaos-ar")
set(CMAKE_RANLIB "ppc-amigaos-ranlib")
add_definitions(-DAMIGAOS4)
set(NOX11 ON)
set(NOEGL ON)
set(NO_GBM ON)
set(CMAKE_C_FLAGS_DEBUG "-O1 -gstabs")
set(CMAKE_C_FLAGS_RELWITHDEBINFO "-O2 -gstabs")
endif()
#NOX11
if(NOX11)
add_definitions(-DEGL_NO_X11)
add_definitions(-DNOX11)
endif()
#NOEGL
if(NOEGL)
add_definitions(-DNOEGL)
add_definitions(-DNOX11)
endif()
if(NOT NO_GBM)
find_package(PkgConfig REQUIRED)
pkg_check_modules(KMSDRM REQUIRED libdrm gbm egl)
link_directories(${KMSDRM_LIBRARY_DIRS})
include_directories(${KMSDRM_INCLUDE_DIRS})
endif()
if(NO_GBM)
add_definitions(-DNO_GBM)
endif()
#NOX11
if(USE_CLOCK)
add_definitions(-DUSE_CLOCK)
endif()
if(NO_LOADER)
add_definitions(-DNO_LOADER)
endif()
if(NO_INIT_CONSTRUCTOR)
add_definitions(-DNO_INIT_CONSTRUCTOR)
endif()
if(GLX_STUBS)
add_definitions(-DGLX_STUBS)
endif()
#DEFAULT_ES=2
if(DEFAULT_ES EQUAL 2)
add_definitions(-DDEFAULT_ES=2)
endif()
if(DEFAULT_ES EQUAL 1)
add_definitions(-DDEFAULT_ES=1)
endif()
if(USE_CCACHE)
find_program(CCACHE_FOUND ccache)
if(CCACHE_FOUND)
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ccache)
set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK ccache)
endif()
endif()
link_directories(${CMAKE_SOURCE_DIR}/lib)
if(NOT WIN32_MSVC)
add_definitions(-std=gnu11 -funwind-tables)
if(NOT WIN32_PLATFORM)
add_definitions(-fvisibility=hidden)
else()
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--exclude-all-symbols,--kill-at")
endif()
elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
add_definitions(-Wno-deprecated-declarations) #strdup
add_definitions(-Wno-unused-function -Wno-unused-variable -Wno-dangling-else)
add_definitions(-Wno-implicit-const-int-float-conversion)
add_definitions(-Wno-visibility)
else()
if(CMAKE_C_COMPILER_ID MATCHES "Intel")
add_definitions(-wd1786 -wd589 -wd537 -wd118 -wd2722)
else()
add_definitions(-wd4996 -wd4244 -wd4267 -wd4098 -wd4018)
endif()
add_definitions("-Dinline=__inline" "-D__func__=__FUNCTION__") # for VC<=13
endif()
if (CMAKE_CC_COMPILER_ID MATCHES "Clang" OR CMAKE_SYSTEM_NAME MATCHES "Emscripten")
add_definitions(-Wno-pointer-sign -Wno-dangling-else)
endif()
include_directories(include)
add_subdirectory(src)
enable_testing()
macro(create_test test_name test_filename calls_count tolerance)
if (${ARGC} EQUAL 5)
add_test(${test_name}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE=${tolerance}
-D EXTRACT_RANGE=${ARGV4}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
elseif (${ARGC} EQUAL 6)
if (${ARGV4} STREQUAL "NOEXTRACT_RANGE")
add_test(${test_name}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE_GLES1=${tolerance}
-D TOLERANCE_GLES2=${ARGV5}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
else (${ARGV4} STREQUAL "NOEXTRACT_RANGE")
add_test(${test_name}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE_GLES1=${tolerance}
-D TOLERANCE_GLES2=${ARGV5}
-D EXTRACT_RANGE=${ARGV4}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
endif (${ARGV4} STREQUAL "NOEXTRACT_RANGE")
else (${ARGC} EQUAL 5)
add_test(${test_name}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE=${tolerance}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
endif (${ARGC} EQUAL 5)
endmacro(create_test)
macro(create_test_GLES test_name GLES test_filename calls_count tolerance)
if (${ARGC} EQUAL 5)
add_test(${test_name}_GLES${GLES}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE=${tolerance}
-D EXTRACT_RANGE=${ARGV4}
-D GLES_FORCED=${GLES}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
else (${ARGC} EQUAL 5)
add_test(${test_name}_GLES${GLES}
${CMAKE_COMMAND}
-D LIBRARY_FOLDER=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
-D TESTS_DIRECTORY=${CMAKE_SOURCE_DIR}/tests
-D TEST_FILENAME=${test_filename}
-D CALLS=${calls_count}
-D TOLERANCE=${tolerance}
-D GLES_FORCED=${GLES}
-P ${CMAKE_SOURCE_DIR}/test.cmake)
endif (${ARGC} EQUAL 5)
endmacro(create_test_GLES)
create_test(GLXgears glxgears "0000008203" 25 "NOEXTRACT_RANGE" 700)
create_test(StuntCarRacer stuntcarracer "0000118817" 20 "638x478+1+1")
create_test(Neverball neverball "0000078750" 20 "798x478+1+1" 200)
create_test(FoobillardPlus foobillardplus "0000014748" 50 "798x478+1+1" 60)
create_test(Descent3 descent3 "0000284192" 20 "638x478+1+1")
create_test(PointSprite pointsprite "0000248810" 20)
#create_test_GLES(Neverball 2 neverball "0000078750" 200 "798x478+1+1")
create_test_GLES(OpenRA 2 openra "0000031249" 20 "638x478+1+1")
create_test_GLES(GLSL_lighting 2 glsl_lighting "0000505393" 20)
+133
View File
@@ -0,0 +1,133 @@
Compiling
====
It's better to define the CMake Build type, preferably `RelWithDebInfo`, that define a Release build (so with some optimizations) but with debug info (so gdb Backtrace shows infos).
*Pandora*
---
`mkdir build; cd build; cmake .. -DPANDORA=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo; make`
*Raspberry Pi*
---
If your are using legacy driver (non-mesa ones)
`mkdir build; cd build; cmake .. -DBCMHOST=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo; make`
For Mesa VC4 driver, use the ODROID profile.
*ODroid*
---
`mkdir build; cd build; cmake .. -DODROID=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo; make`
*Tinker Board 1/1S or RK3288*
---
use ODROID profile.
*OrangePI*
---
use ODROID profile.
*CHIP machines*
---
`mkdir build; cd build; cmake .. -DCHIP=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo; make`
*Emscripten*
---
`mkdir build; cd build; emcmake cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DNOX11=ON -DNOEGL=ON -DSTATICLIB=ON; make`
*Android*
---
An Android.mk is provided that should compile with an NDK
*iOS*
---
```
mkdir build
cd build
cmake .. -G Xcode -DCMAKE_TOOLCHAIN_FILE={where ios.toolchain.cmake is located} -DNOX11=ON -DNOEGL=ON -DSTATICLIB=ON -DPLATFORM=OS
cmake --build . --config Release --target GL
```
Use with [ios-cmake](https://github.com/leetal/ios-cmake)
*Custom build*
---
Alternatively, you can use the curses-bases ccmake (or any other gui frontend for cmake) to select wich platform to use interactively.
You can avoid the use of X11 with `NOX11=1` and EGL with `NOEGL=1`, but then, you will need to create the EGL Context yourself (or using SDL for example). Be sure to synchronize the context you create with the Backend you use. By default GLES 1.1 backend is used. To used GLES2 by default, use `DEFAULT_ES=2`
You can use USE_CLOCK to use `clock_gettime(...)` instead of `gettimeofday(...)` for LIBGL_FPS. It can be more precise on some platform. Add `-DUSE_CLOCK=ON`
You can use cmake and mix command line argument to custom build your version of GL4ES. For example, for a generic ARM with NEON platform, not using X11 or EGL, defaulting to GLES2 backend, enabling RPi, and using clock_gettime, you would do:
`mkdir build; cd build; cmake .. -DBCMHOST=1 -DNOEGL=1 -DNOX11=1 -DDEFAULT_ES=2 -DUSE_CLOCK=ON -DCMAKE_C_FLAGS="-marm -mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard" -DCMAKE_BUILD_TYPE=RelWithDebInfo; make`
----
Testing
====
A few tests are included.
They can be launched with `tests/tests.sh`
You will need apitrace and imagemagick for them to run. (on debian and friend, it's `sudo apt install apitrace-gl-frontend imagemagick`)
The tests use a pre-recorded GL trace that is replayed, then a specific frame is captured and compared to a reference picture.
Because each renderer may render slightly differently, there are some fuzz in the comparison, so only significant changes will be detected.
For now, 2 tests are done, one with glxgears (basic testing, using mostly glBegin / glEnd) and stuntcarracer (with more GL stuff, textures and lighting).
----
Per-platform
====
## OpenPandora
This is the main developpement platform for now. GL4ES works on all 3 models (CC, Rebirth and Gigahertz), and with all driver version.
For the SGX Driver version that doesn't support X11 Windowed mode (i.e. there is no `/usr/lib/libpvrPVR2D_X11WSEGL.so` library in the firmware), you need to use one of the framebuffer output: `LIBGL_FB=1` is the fastest, but you may need `LIBGL_FB=2` if you need 32bits framebuffer, or `LIBGL_FB=3` if you need GL in a Window (but it will be slow, as each frame has to be blitted back in the X11 windows).
On the Pandora, the special `LIBGL_GAMMA` can be used also, to boost gamma at load, using firmware command to change LCD gamma.
## ODroid
GL4ES works well on ODroid. I can test on XU4 model, but it has been reported to work on all model, including 64bits version.
On ODroid, the EGL context can be created with SRGB attribute, by using `LIBGL_SRGB=1`, for a nice boost in the gamma (supported on most ODroid).
## OrangePI
GL4ES works on OrangePI using ODROID profile. GLES Hardware is MALI and is similar to ODroid in many way. I don't own any OrangePI but I have seen many success in compiling and using the lib.
## C.H.I.P.
GL4ES should work on CHIP and PocketCHIP. Framebuffers mode will probably not work, with only `LIBGL_FB=3` mode that seems to work fine, and with adequate performances (there is probably no slow blit, as the driver handle directly the GL->X11 blit).
Also, on the CHIP, you will probably need to do `sudo apt-get install chip-mali-userspace` to be sure the GLES driver of the CHIP is present.
## RaspberryPI
GL4ES works on RaspberryPI, on both legacy driver and mesa opensourced ones.
For Legacy driver, the Framebuffer mode should work on this plateform. It seems `LIBGL_FB=3` gives good result and should be used in most cases. `LIBGL_FB=1` try to use `DispManX` for more speed. Be aware that if you use X-less config, many SDL version dedicated to DispManX do not handle GL context creation. So trying to create a GL context with this version of SDL will fail, but GL4ES is never called, so I cannot "fix" that (the fix would be inside SDL/DispManX driver).
## Android
On Android build of GL4ES, no X11 function are called. So most `glX` function are not defined. That means that the GL context (in fact a GLES1.1 context) has to be created by other apps (mainly SDL or SDL2).
On Android version 4.0 and earlier, there is a bug that prevent dlopen to be called inside dlopen (see [here](http://grokbase.com/t/gg/android-ndk/124bdvscqx/block-with-calling-dlopen-and-dlclose)).
GL4ES use a "library constructor" to initialize itself a load, and that constructor do some dlopen (to load EGL and GLES libraries), so it will trigger that bug.
If you are targeting a wide range of device, you should probably activate the workaround:
1. Modify [Android.mk](Android.mk) to uncomment `#LOCAL_CFLAGS += -DNO_INIT_CONSTRUCTOR` near the end of the file, to prevent the use of the library constructor.
2. In your code, call `void initialize_gl4es()` as soon as possible after loading GL4ES, and before using any GL function.
To try the GLES2 backend, you can compile gl4es with ES2 by default (so you don't have to mess with env. variable). Simply uncomment `#LOCAL_CFLAGS += -DDEFAULT_ES=2`, and create the GL Context as GLES2.
## Emscripten
In your code, call `void initialize_gl4es()` as soon as possible after loading GL4ES, and before using any GL function.
Use `-s FULL_ES2=1 -I[directory where GL4ES source code is located]/gl4es/include -lGL` when compiling your program for Emscripten.
## iOS
In your code, call `void initialize_gl4es()` as soon as possible after loading GLES context, and before using any GL function. If you use EAGLContext, call it immediately after `[EAGLContext setCurrentContext:context]`.
+390
View File
@@ -0,0 +1,390 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<CodeBlocks_project_file>
<FileVersion major="1" minor="6" />
<Project>
<Option title="GL4ES" />
<Option pch_mode="0" />
<Option compiler="gcc" />
<Build>
<Target title="Linux Static">
<Option output="./libGL4ES.a" prefix_auto="0" extension_auto="0" />
<Option working_dir="" />
<Option type="2" />
<Option compiler="gcc" />
<Option projectResourceIncludeDirsRelation="1" />
</Target>
<Target title="Linux Dynamic">
<Option output="./libGL.so" prefix_auto="0" extension_auto="0" />
<Option type="3" />
<Option compiler="gcc" />
<Option projectResourceIncludeDirsRelation="1" />
</Target>
<Target title="Amiga Dynamic">
<Option output="./libGL.so" prefix_auto="0" extension_auto="0" />
<Option type="3" />
<Option compiler="gcc" />
<Option projectResourceIncludeDirsRelation="1" />
</Target>
</Build>
<VirtualTargets>
<Add alias="All" targets="Linux Static;" />
</VirtualTargets>
<Compiler>
<Add option="-DNO_GBM" />
<Add directory="/home/Dev/libs/game/Irrlicht-SVN/include" />
<Add directory="include" />
</Compiler>
<Linker>
<Add directory="/home/Dev/libs/game/Irrlicht-SVN/lib/Linux" />
</Linker>
<Unit filename="include/EGL/egl.h" />
<Unit filename="include/EGL/eglext.h" />
<Unit filename="include/EGL/eglplatform.h" />
<Unit filename="include/GL/gl.h" />
<Unit filename="include/GL/gl_mangle.h" />
<Unit filename="include/GL/glext.h" />
<Unit filename="include/GL/glu.h" />
<Unit filename="include/GL/glu_mangle.h" />
<Unit filename="include/GL/glx.h" />
<Unit filename="include/GL/glx_mangle.h" />
<Unit filename="include/GL/glxext.h" />
<Unit filename="include/GL/internal/dri_interface.h" />
<Unit filename="include/GLES/egl.h" />
<Unit filename="include/GLES/gl.h" />
<Unit filename="include/GLES/gl2.h" />
<Unit filename="include/GLES/gl2ext.h" />
<Unit filename="include/GLES/gl2platform.h" />
<Unit filename="include/GLES/gl3.h" />
<Unit filename="include/GLES/gl3ext.h" />
<Unit filename="include/GLES/gl3platform.h" />
<Unit filename="include/GLES/glext.h" />
<Unit filename="include/GLES/glplatform.h" />
<Unit filename="include/KHR/khrplatform.h" />
<Unit filename="include/android_debug.h" />
<Unit filename="include/gl4eshint.h" />
<Unit filename="include/gl4esinit.h" />
<Unit filename="include/khash.h" />
<Unit filename="src/agl/agl.c">
<Option compilerVar="CC" />
<Option target="Amiga Dynamic" />
</Unit>
<Unit filename="src/agl/agl.h">
<Option target="Amiga Dynamic" />
</Unit>
<Unit filename="src/agl/amigaos.c">
<Option compilerVar="CC" />
<Option target="Amiga Dynamic" />
</Unit>
<Unit filename="src/agl/amigaos.h">
<Option target="Amiga Dynamic" />
</Unit>
<Unit filename="src/agl/lookup.c">
<Option compilerVar="CC" />
<Option target="Amiga Dynamic" />
</Unit>
<Unit filename="src/gl/arbconverter.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/arbconverter.h" />
<Unit filename="src/gl/arbgenerator.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/arbgenerator.h" />
<Unit filename="src/gl/arbhelper.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/arbhelper.h" />
<Unit filename="src/gl/arbparser.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/arbparser.h" />
<Unit filename="src/gl/array.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/array.h" />
<Unit filename="src/gl/attributes.h" />
<Unit filename="src/gl/blend.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/blend.h" />
<Unit filename="src/gl/blit.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/blit.h" />
<Unit filename="src/gl/buffers.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/buffers.h" />
<Unit filename="src/gl/build_info.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/build_info.h" />
<Unit filename="src/gl/const.h" />
<Unit filename="src/gl/debug.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/debug.h" />
<Unit filename="src/gl/decompress.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/decompress.h" />
<Unit filename="src/gl/depth.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/depth.h" />
<Unit filename="src/gl/directstate.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/directstate.h" />
<Unit filename="src/gl/drawing.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/enable.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/enum_info.h" />
<Unit filename="src/gl/envvars.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/envvars.h" />
<Unit filename="src/gl/eval.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/eval.h" />
<Unit filename="src/gl/face.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/face.h" />
<Unit filename="src/gl/fog.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/fog.h" />
<Unit filename="src/gl/fpe.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/fpe.h" />
<Unit filename="src/gl/fpe_cache.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/fpe_cache.h" />
<Unit filename="src/gl/fpe_shader.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/fpe_shader.h" />
<Unit filename="src/gl/framebuffers.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/framebuffers.h" />
<Unit filename="src/gl/getter.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/gl4es.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/gl4es.h" />
<Unit filename="src/gl/gl_lookup.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/gl_lookup.h" />
<Unit filename="src/gl/glcase.h" />
<Unit filename="src/gl/gles.h" />
<Unit filename="src/gl/glesfuncs.inc" />
<Unit filename="src/gl/glstate.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/glstate.h" />
<Unit filename="src/gl/hint.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/hint.h" />
<Unit filename="src/gl/init.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/init.h" />
<Unit filename="src/gl/light.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/light.h" />
<Unit filename="src/gl/line.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/line.h" />
<Unit filename="src/gl/list.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/list.h" />
<Unit filename="src/gl/listdraw.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/listrl.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/loader.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/loader.h" />
<Unit filename="src/gl/logs.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/logs.h" />
<Unit filename="src/gl/math/eval.h" />
<Unit filename="src/gl/math/matheval.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/matrix.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/matrix.h" />
<Unit filename="src/gl/matvec.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/matvec.h" />
<Unit filename="src/gl/oldprogram.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/oldprogram.h" />
<Unit filename="src/gl/pixel.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/pixel.h" />
<Unit filename="src/gl/planes.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/planes.h" />
<Unit filename="src/gl/pointsprite.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/pointsprite.h" />
<Unit filename="src/gl/preproc.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/preproc.h" />
<Unit filename="src/gl/program.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/program.h" />
<Unit filename="src/gl/queries.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/queries.h" />
<Unit filename="src/gl/raster.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/raster.h" />
<Unit filename="src/gl/render.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/render.h" />
<Unit filename="src/gl/samplers.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/samplers.h" />
<Unit filename="src/gl/shader.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/shader.h" />
<Unit filename="src/gl/shader_hacks.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/shader_hacks.h" />
<Unit filename="src/gl/shaderconv.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/shaderconv.h" />
<Unit filename="src/gl/stack.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/stack.h" />
<Unit filename="src/gl/state.h" />
<Unit filename="src/gl/stb_dxt_104.h" />
<Unit filename="src/gl/stencil.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/stencil.h" />
<Unit filename="src/gl/string_utils.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/string_utils.h" />
<Unit filename="src/gl/stubs.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texenv.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texenv.h" />
<Unit filename="src/gl/texgen.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texgen.h" />
<Unit filename="src/gl/texture.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texture.h" />
<Unit filename="src/gl/texture_3d.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texture_compressed.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texture_params.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/texture_read.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/uniform.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/uniform.h" />
<Unit filename="src/gl/vertexattrib.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/vertexattrib.h" />
<Unit filename="src/gl/wrap/gl4es.h" />
<Unit filename="src/gl/wrap/gl4eswraps.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/wrap/gles.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/wrap/gles.h" />
<Unit filename="src/gl/wrap/glstub.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/gl/wrap/skips.h" />
<Unit filename="src/gl/wrap/stub.h" />
<Unit filename="src/glx/drmfunc.h" />
<Unit filename="src/glx/gbm.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/gbmfunc.h" />
<Unit filename="src/glx/glx.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/glx.h" />
<Unit filename="src/glx/glx_gbm.h" />
<Unit filename="src/glx/glx_stubs.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/hardext.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/hardext.h" />
<Unit filename="src/glx/lookup.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/rpi.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/rpi.h" />
<Unit filename="src/glx/streaming.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/streaming.h" />
<Unit filename="src/glx/utils.c">
<Option compilerVar="CC" />
</Unit>
<Unit filename="src/glx/utils.h" />
<Extensions />
</Project>
</CodeBlocks_project_file>
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2016-2018 Sebastien Chevalier
Copyright (c) 2013-2016 Ryan Hileman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+61
View File
@@ -0,0 +1,61 @@
GL4ES - OpenGL for GLES Hardware
====
Many OpenGL software already works with GL4ES.
![foobillards++](refs/foobillardplus.0000014748.png)
Complex OpenGL 1.5 games, like Foobillard++, that uses cascaded display list, line stipple and TexGen works fine. Here running on the OpenPandora. It will work with both GLES1.1 and GLES2.0 backend. Most OpenGL 1.x games will runs.
[![Play on Youtube](https://img.youtube.com/vi/75FYb60L7zw/0.jpg)](https://www.youtube.com/watch?v=75FYb60L7zw)
The limit is an hardware one: GLES1.1 and GLES2.0 hardware do not allow reading Depth buffer, so some games that use it (like AssaultCube) for game play wont run correctly. Some FPS are using it just for cosmetics (Lens flare) and run fine without, like Serious Sam (both First and Second Encounter), here on the OpenPandora again.
![AssaultCube on Android](media/assaultcube.jpg)
AssaultCube an Android also use gl4es for the rendering
[![Play on Youtube](https://img.youtube.com/vi/kJPb2jYiBoM/0.jpg)](https://www.youtube.com/watch?v=kJPb2jYiBoM)
More complex program, like Blender, can also run.
![MineCraft](media/minecraft.png)
But also Minecraft, here on an ODroid in this old video.
![OpenRA](refs/openra.0000031249.png)
On the OpenGL 2.0, side using GLES2.0 backend, OpenRA can run on the Pandora.
And OpenGL 2.x opens a lot of doors. Many commercial games run just fine on gl4es.
[![Play on Youtube](https://img.youtube.com/vi/VUoeHWuwlMU/0.jpg)](https://www.youtube.com/watch?v=VUoeHWuwlMU)
Here some FNA games, running on an ODroid XU4 (using method described [there](https://magazine.odroid.com/article/playing-modern-fna-games-on-the-odroid-platform/)). That video show just a few, and even more can be make to run.
[![Play on Youtube](https://img.youtube.com/vi/B4YN37z3-ws/0.jpg)](https://www.youtube.com/watch?v=B4YN37z3-ws)
And with [Box86](https://github.com/ptitSeb/box86), even more commercial games can run, like here NeverWinter Night on an ODroid XU4
GL4ES also now works on some other platform, like AmigaOS4 (so BigEndian platform)
[![Play on Youtube](https://img.youtube.com/vi/hQVabA_ReoQ/0.jpg)](https://www.youtube.com/watch?v=hQVabA_ReoQ)
Here running Foobillard++
Or even in a web browser (that support WebGL): try it with Stunt Car Remake [here](http://ptitseb.github.io/stuntcarremake/)
Another web browser example: Serious Sam Engine, try it [here](https://martinmullins.github.io/ssam/) (initial 80MB game files download).
[source repo](https://github.com/martinmullins/Serious-Engine)
[![Gif of Serious Engine](https://github.com/martinmullins/ssam/blob/main/ssam.gif?raw=true)](https://martinmullins.github.io/ssam/)
Neverball in a browser also use gl4es for rendering: [neverball in a browser](https://neverball.github.io/)
About performances: while gl4es is a wrapper, there are many caching and works done in gl4es to avoid slowdown and bottleneck. While those optimization are useless in highly optimized engine, like the Quake3 engine, on other engine and games, gl4es can be (much) faster than straight GLES conversion (especially if there are a lot of `glBegin(...)`/`glEnd()` drawing commands). On an x86 VM, gl4es running on GLES can be faster than direct OpenGL use on some games!
Now GL4ES can also use VBO, either when the software use it, or automatically when using glList, giving sometimes a nice boost (the boost depend on the Platform and game)
+95
View File
@@ -0,0 +1,95 @@
![logo](gl4es.png "gl4es logo")
GL4ES - OpenGL for GLES Hardware
====
![gl4es build status](https://api.travis-ci.org/ptitSeb/gl4es.png "gl4es build status")
This is a library provide OpenGL 2.x functionality for GLES2.0 accelerated Hardware (and of course also support OpenGL 1.5 function, sometimes better than when using GLES 1.1 backend)
There is also support for GLES 1.1 Hardware, emulating OpenGL 1.5, and some OpenGL 2.x+ extensions.
GL4ES is known to work on many platform: OpenPandora, ODroid, RaspberryPI (2 and 3 at least), PocketCHIP, "otherfruit"PI (like the OrangePI), Android, iOS, x86 and x86_64 Linux (tested using mesa-egl). There is also some WIP support for AmigaOS4, using experimental GLES2 driver for Warp3D.
This library is based on glshim (https://github.com/lunixbochs/glshim) but as now evolved far from it, with different feature set and objectives. Go check this lib if you need things like RemoteGL or TinyGLES (for software rendering).
The focus is on compatibility and speed with a wide selection of game and software.
It has been tested successfully of a large selection of games and software, including: Minecraft, OpenMW, SeriousSam (both First and Second Encounters), RVGL (ReVolt GL), TSMC (The Secret Maryo Chronicles), TORCS, SpeedDreams, GL-117, Foobillard(plus), half life 1&2, Blender 2.68 to name just a few. I have also some success with Linux port of XNA games, using either MonoGame or FNA.
Most function of OpenGL up to 1.5 are supported, with some notable exceptions:
* Reading of Depth or Stencil buffer will not work
* GL_FEEDBACK mode is not implemented
* No Accum emulation
Some known general limitations:
* GL_SELECT as some limitation in its implementation (for example, current Depth buffer or bounded texture are not taken into account, also custom vertex shader will not work here)
* NPOT texture are supported, but not with GL_REPEAT / GL_MIRRORED, only GL_CLAMP will work properly (unless the GLES Hardware support NPOT)
* Multiple Color attachment on Framebuffer are not supported
* OcclusionQuery is implemented, but with a 0 bits precision
* Probably many other things
Status of the GLES2 backend
* The FPE (Fixed Pipeline Emulator) has most OpenGL 1.5 drawing call implemented
* The Shader Conversion is really crude, so only simple shaders will work (especially, the implicit conversion float <-> int is not handled)
* ARB_program are supported (converted on-the-fly to glsl shaders)
* Lighting support double-side and color separation
* FogCoord are supported, along with secondary color
* An ES2 context should be usable (useful for SDL2)
* OpenGL 2.x games that have been tested include: OpenRA, GZDoom, Danger from the Deep, SuperTuxKart 0.8.1, Hammerwatch, OpenMW, half life 2, many FNA & MonoGames games (FEZ, Towerfall Ascension, Stardew Valley, Dust, Owlboy, and many other), even some Unity3D games (Teslagrad, Colin McRea Rally remake and other)...
* glxgears works, but FlatShade is not implemented (and will probably never be), so it's slightly different than using GLES1.1 or actual GL hardware
* GL_TEXTURE_1D, GL_TEXTURE_3D and GL_TEXTURE_RECTANGLE_ARB are not yet supported in shaders (they are supported in fixed pipeline functions), and texture 3D are just a single 2D layer for now.
* Program that link only a GL_FRAGMENT or GL_VERTEX shader are not supported yet.
* Some VBO are used.
Status of the GLES1.1 backend
* Framebuffer use FRAMEBUFFER_OES extension (that must be present in the GLES 1.1 stack)
* Lighting doesn't support double-side or color separation
* FogCoord or Secondary colors are not supported
* GL_TEXTURE_3D are just a single 2D layer (the 1st layer).
* VBO are supported, but they are emulated, even if VBO if supported in GLES1.1 driver
If you use gl4es in your project (as a static or dynamic link), please mention gl4es in you readme / about / whatever.
----
Compiling
----
How to compile and per-platform specific comment can be found [here](COMPILE.md)
----
GLU
----
Standard GLU do works without any issues. You can find a version [here](https://github.com/ptitSeb/GLU) if you need one.
----
Installation
----
Put lib/libGL.so.1 in your `LD_LIBRARY_PATH`.
Beware that GL4ES is meant to replace any libGL you can have on your system (like Mesa for example)
----
Usage
----
There are many environment variable to control gl4es behavior, also usable at runtime using `glHint(...)`.
See [here](USAGE.md) for all variables and what they do.
----
Media (what is working already)
----
Some screenshot and youtube links of stuffs that works [here](MEDIA.md)
----
Version history
----
The change log is [here](CHANGELOG.md)
+387
View File
@@ -0,0 +1,387 @@
Usage
----
There are many environment variables to control gl4es behavior. All are numeric, except LIBGL_VERSION that take a string, LIBGL_FBO that takes a 2d size (WxH), and both LIBGL_EGL and LIBGL_GLES that take path/filename.
You can also change many of this variable at runtime using the `glHint(...)` function. See [gl4eshint.h](include/gl4eshint.h) for the list of #define to use in this function.
##### LIBGL_FB
Controls the Framebuffer output
* 0 : Default, using standard x11 rendering
* 1 : Use Framebuffer output (x11 bypassed, only fullscreen)
* 2 : Use Framebuffer, but also an intermediary FBO
* 3 : Use PBuffer, allowing x11 (and windowed) rendering even if driver doesn't support it
##### LIBGL_FBONOALPHA
In case of LIBGL_FB=2, control if FBO is RGBA or RGB
* 0 : Default, use RGBA
* 1 : Use RGB for FBO
##### LIBGL_ES
Controls the version of GLES to use
* 0 : Default, using GLES 2.0 backend (unless built with DEFAULT_ES 1) (not on Pandora, still GLES 1.1 backend by default)
* 1 : Use GLES 1.1 backend
* 2 : Use GLES 2.0 backend
##### LIBGL_GL
Controls the version of OpenGL exposed
* 0 : Default, expose OpenGL 1.5 when using GLES1.1 or OpenGL 2.1 when using GLES2.0
* 10..14: Export OpenGL 1.0-1.4
* 15: Expose OpenGL 1.5 (default for GLES 1.1 backend)
* 20: Expose OpenGL 2.0
* 21: Expose OpenGL 2.1 (default for GLES 2.0 backend)
##### LIBGL_XREFRESH
Debug helper in specific cases
* 0 : Default, nothing special
* 1 : xrefresh will be called on cleanup
##### LIBGL_STACKTRACE
Automatic Backtrace log
* 0 : Default, nothing special
* 1 : stacktrace will be printed on crash
##### LIBGL_FPS
Print current FPS to the console
* 0 : Defaut, don't measure or printf FPS
* 1 : Print FPS (on stdout) every second
##### LIBGL_VSYNC
VSync control
* 0 : Default, nothing special
* 1 : vsync enabled
##### LIBGL_RECYCLEFBO
Recycling FBO special case (don't delete a created FBO, but recycle it if needed)
* 0 : Default, nothing special
* 1 : Recycling of FBO enabled
##### LIBGL_MIPMAP
Handling of Manual and Automatic MIPMAP
* 0 : Default, nothing special
* 1 : AutoMipMap forced
* 2 : guess AutoMipMap (based on manual mipmaping on textures)
* 3 : ignore MipMap (mipmap creation / use entirely disabled)
* 4 : ignore AutoMipMap on non-squared textures
* 5 : calculate all sub-mipmap one time when uploading level 1
##### LIBGL_FORCENPOT
Forcing NPOT (Non-Power of Two) Texture size.
* 0 : Default, nothing special
* 1 : If hardware only support Limited NPOT, then disabling MIPMAP (i.e. LIBGL_MIPMAP=3), so all texture can be NPOT.
If Hardware support full NPOT, do nothing special. Useful for GLES2 backend where limited NPOT is always supported.
##### LIBGL_TEXCOPY
Make a local copy of every texture for easy glGetTexImage2D
* 0 : Default, nothing special
* 1 : Texture copy enabled
##### LIBGL_SHRINK
Texture shrinking control
* 0 : Default, nothing special
* 1 : everything / 2 (using original algorithm for size reduction, all other shrink mode use a refined algorithm)
* 2 : only textures which one size > 512 are / 2
* 3 : only textures which one size > 256 are / 2
* 4 : only textures which one size > 256 are / 2, and the one > 1024 are / 4
* 5 : only textures which one size > 256 are resized to 256 (if possible, because only /2 and /4 exists), but empty texture are not shrunken
* 6 : only textures which one size > 128 are / 2, those >= 512 are resized to 256 (if possible, because only /2 and /4 exists), but empty texture are not shrunken
* 7 : only textures which one size > 512 are / 2, but empty texture are not shrunken
* 8 : advertise a max texture size *4, but every texture which one size > 2048 are shrunken to 2048
* 9 : advertise a max texture size *4, but every texture which one size > 4096 are / 4 and the one > 512 are / 2, but empty texture are not shrunken
* 10: advertise a max texture size *4, but every texture which one size > 2048 are / 4 and the one > 512 are / 2, but empty texture are not shrunken
* 11: advertise a max texture size *2, but every texture with one dimension > max texture size will get shrunken to max texture size
##### LIBGL_TEXDUMP
Texture dump
* 0 : Default, nothing special
* 1 : Texture dump enabled
##### LIBGL_ALPHAHACK
Experimental: enable Alpha test only when using texture that contains an alpha channel
* 0 : Default, nothing special
* 1 : Alpha Hack enabled
##### LIBGL_NODOWNSAMPLING
Texture downsampling control (deprecated, use LIBGL_AVOID16BITS instead)
* 0 : Default, DXTc texture are downsampled to 16bits
* 1 : DXTc texture are left as 32bits RGBA
##### LIBGL_STREAM
PANDORA only: enable Texture Streaming (works only on RGB textures)
* 0 : Default, nothing special
* 1 : Enabled on empty RGB textures
* 2 : Enabled on all RGB textures
##### LIBGL_COPY
Removed (Controlled the glCopyTex(Sub)Image2D hack, it's now automatic, depending on how compatible is the read framebuffer)
##### LIBGL_NOLUMALPHA
Control the availability of the LUMINANCE_ALPHA format (can be buggy on Pandora model CC)
* 0 : Default,GL_LUMINANCE_ALPHA is available and used if needed
* 1 : GL_LUMINANCE_ALPHA hardware support disabled (a GL_RGBA texture will be used instead)
##### LIBGL_BLENDHACK
Experimental: Change Blend GL_SRC_ALPHA, GL_ONE to GL_ONE, GL_ONE
* 0 : Default, nothing special
* 1 : Change Blend GL_SRC_ALPHA, GL_ONE to GL_ONE, GL_ONE (can be usefull for Xash3D engine)
##### LIBGL_BLENDCOLOR
Hack: Export a (faked) glBlendColor
* 0 : Default, don't expose gBlendColor
* 1 : Exposed the function (if no hardware support, faked function will be used)
##### LIBGL_VERSION
Hack: Control the glGetString version. Override version string (should be in the form of "1.x")
##### LIBGL_BATCH
This has been changed with v1.0.5.
Now BATCH simply try to merge subsequent glDrawXXXXX (glDrawArrays, glDrawElements...). It only try to merge if arrays is between MINBATCH and MAXBATCH (inclusive)
The Batching stop when there is a change of GL State, but also if an Array of more then 100*N is encountered.
* 0 : Default: don't try to merge glDrawXXXXX
* N : Any number: try to merger arrays, 1st must be between 0 and 100*N
* MIN-MAX : 2 number separated by minus, to try merge arrays that are between MIN and MAX vertices
##### LIBGL_NOERROR
Hack: glGetError() always return GL_NOERROR
* 0 : Default, glGetError behave as it should
* 1 : glGetError never fail.
##### LIBGL_GAMMA
Pandora Hack: Set a Gamma value (in decimal formal, 1.0 means no gamma boost)
* X.Y : Use X.Y as gamma when creating context (typical value can be 1.6 or 2.0)
##### LIBGL_SRGB
ODROID Hack: Enable sRGB Surface (so Gamma corrected), if Hardware support it
* 0 : Default, don't try to use sRGB surface
* 1 : Enable sRGB Surface (but support will be tested first, must have EGL_KHR_gl_colorspace extension)
##### LIBGL_FASTMATH
Hack: Activate some Fast Math in processor/co-processor
* 0 : Default, nothing special
* 1 : On OpenPandora and CHIP, activate "RunFast" on Cortex-A8 (mode default NaN, flush-to-zero)
: Not implemented on other platforms (will do nothing)
##### LIBGL_SILENTSTUB
Debug: Hide or Show the Sub / Not found message
* 0 : The messages for Stub or absent function are printed
* 1 : Default, don't print the STUB or glXGetProcAddress glXXXXX not found message
##### LIBGL_NOBANNER
Show/Hide initial text
* 0 : Default, print starting message
* 1 : Silent: no LIBGL message at start (combine with LIBGL_SILENTSTUB for more silence)
##### LIBGL_NPOT
Expose NPOT (Non Power of Two) Support
* 0 : Default, expose the extension that are available by the GLES backend
* 1 : Expose limited NPOT extension
* 2 : Expose GL_ARB_texture_non_power_of_two extension
##### LIBGL_GLQUERIES
Expose glQueries functions
* 0 : Don't expose the function (fake one will be used if called)
* 1 : Default, expose fake functions (always answer 0)
##### LIBGL_NOTEXMAT
Handling of Texture Matrix
* 0 : Default, perform handling internally (better handling of NPOT texture on all hardware)
* 1 : Let the driver handle texmat (can be faster in some cases, but NPOT texture may be broken)
##### LIBGL_NOTEST
Initial Hardware test
* 0 : Default, perform initial hardware testing (using a PBuffer)
* 1 : Do not perform test (no extensions tested or used)
##### LIBGL_NOVAOCACHE
VAO Caching
* 0 : Default, try to cache vao to avoid memcpy in render list
* 1 : Don't cache VAO
##### LIBGL_VABGRA
Vertex Array BGRA extension
* 0 : Default, GL_ARB_vertex_array_bgra not exposed (still emulated)
* 1 : Extension exposed may be faster in some cases (Arx Libertatis mainly)
##### LIBGL_BEGINEND
Merge of subsequent glBegin/glEnd blocks (will be non-effective if BATCH mode is used)
* 0 : Don't try to merge
* 1 : Try to merge, even if there is a glColor / glNormal in between (default)
##### LIBGL_AVOID16BITS
Try to avoid 16bits textures
* 0 : Default on ImgTec hardware, use 16bits texture if it can avoid a conversion or for DXTc textures
* 1 : Default on all other hardware, Use 32bits texture unless specifically requested (using internalformat)
##### LIBGL_AVOID24BITS
Try to avoid 24bits textures (i.e. GL_RGB)
* 0 : Default, use 24bits texture when it's possible
* 1 : Force 32bits textures when GL_RGB is asked (as internal or not). Not recommended, as it may break some blend functions (especially on GLES 1.1 backend). Does not impact 16bits formats.
##### LIBGL_FORCE16BITS
Try to use 16bits textures
* 0 : Default, don't force 16bits texture
* 1 : Use 16bits texture instead of 32bits (i.e. use RGBA4 instead of RGBA8 and RGB5 instead of RGB8)
##### LIBGL_POTFRAMEBUFFER
Use only Power Of Two dimension for Framebuffer
* 0 : Default, use NPOT dimension if supported
* 1 : Force Framebuffer to be created with POT dimension (not advised on GLES2 backend)
##### LIBGL_NOBGRA
Ignore BGRA Texture hardware extension
* 0 : Default, use BGRA extension if possible
* 1 : Ignore BGRA extension, even if supported by GLES hardware
##### LIBGL_NOTEXRECT
Don't expose Texture rectangle extension (GL_ARB_texture_rectangle)
* 0 : Default, the extension is listed
* 1 : Don't expose the extension (it's not supported in shaders yet)
##### LIBGL_NOHIGHP
Usage of highp precision in fragment shader (ES2 backend only)
* 0 : Default, use highp if available
* 1 : Disable usage of highp in Fragment shaders
##### LIBGL_COMMENTS
Comments in shaders are kept (also for generated shaders by fpe_shaders)
* 0 : Default, no comments in shaders sent to GLES Hardware
* 1 : Comments are left in Shaders sent to GLES Hardware
##### LIBGL_DEFAULTWRAP
Hack to define default WRAP mode for texture
* 0 : Default wrap mode is GL_REPEAT (normal OpenGL behavour): default on NPOT hardware
* 1 : Default wrap mode is GL_CLAMP_TO_EDGE: default on limited NPOT or non-NPOT hardware
* 2 : Default wrap mode is GL_CLAMP_TO_EDGE, enforced (not advised)
##### LIBGL_FBOUNBIND
Workaround on FBO where a bond texture is used for drawing
* 0 : Disabled (Default for all other configuration)
* 1 : Enabled (Default on ARM and PowerVR hardware)
##### LIBGL_FBOFORCETEX
For the Color Attachment 0 to be a Texture2D (even if program attachs a Renderbuffer) => may speedup glBlitFramebuffer if used
* 0 : Don't force (allow Renderbuffer to be used on color attachments)
* 1 : Default: For Color Attachment 0 of FBO to be a texture
##### LIBGL_BLITFULLSCREEN
Hack to trigger a SwapBuffers when a Full Framebuffer Blit on default FBO is done
* 0 : Don't force
* 1 : Default: Activate the hack (usefull with Wine/D3D)
##### LIBGL_NOARBPROGRAM
Don't expose ARB Program extensions (GL_ARB_vertex_program, GL_ARB_fragment_program and GL_EXT_program_parameters)
* 0 : Default: expose the ARB Program extensions
* 1 : Don't expose the extensions
##### LIBGL_FBO
Hack: define custom dimension for FBO (only used with LIBGL_FBO=2)
* WxH : Define FBO of WxH size (ex: LIBGL_FBO=1280x720)
##### LIBGL_NOTEXARRAY
Hack to force using discrete Texture instead of Array in all shader
* 0 : Default: Array of texture is used in shaders (not in FPE generated ones)
* 1 : Individual texture are forced in shaders (shaders may fail to compile if array are accessed by indice)
##### LIBGL_LOGSHADERERROR
Log to the console Shader Compile error, with initial and ShaderConv'd source of the shader
* 0 : Default, don't log
* 1 : Log Shader Compilation Errors
##### LIBGL_SHADERNOGLES
Don't use GL_ES part in shaders
* 0 : Default, let GL_ES part in shader
* 1 : Remove the GL_ES part in shader (useful for Löve for example)
##### LIBGL_NODEPTHTEX
Disable the use of Depth texture
* 0 : Default, Use Depth Texture if supported by Hardware
* 1 : Disable the use of Depth Texture (renderbuffer will be used in FBO)
##### LIBGL_FLOAT
Expose support for FLOAT and HALF_FLOAT Texture support (and has attachment to FBO)
* 0 : Don't exposed, even if supported in hardware
* 1 : Default, exposed what is supported by hardware
* 2 : Force exposed, even if no supported (will be emulated has GL_UNSIGNED_BYTE if not supported)
##### LIBGL_GLXRECYCLE
Recycle EGLSurface per Drawable, instead of destroying them
* 0 : Default, don't recycle
* 1 : Don't destroy EGLSurface, per reused them per drawable (can fix EGL_BAD_ALLOC error). EGLSurface are never destroyed in this mode for now.
##### LIBGL_NOCLEAN
Debug: don't clean GLContext when they are destroy
* 0 : Default, clean GLContext
* 1 : Don't clean GLContext
##### LIBGL_EGL
Define EGL lib to use. Default folder are the standard one for dynamic library loading (LD_LIBRARY_PATH and friend) plus "/opt/vc/lib/", /usr/local/lib/" and "/usr/lib/".
* by default try to use libbrcmEGL and libEGL
* filename: try to load from the defaults folder (don't forget to use complete filename, with ".so" extension). If not found/loaded, default one will be tried.
* /path/to/filename: try to use exact path/filename. If not found/loaded, default one will be tried.
##### LIBGL_GLES
Define GLES(2) lib to use. Default folder are the standard one for dynamic library loading (LD_LIBRARY_PATH and friend) plus "/opt/vc/lib/", /usr/local/lib/" and "/usr/lib/". Be sure to point to correct GLES library depending on wich GLES backend you are using.
* by default try to use libGLESv1_CM, libGLES_CM or libbrcmGLESv1_CM for GLES1.1 and libGLESv2_CM, libGLESv2 or libbrcmGLESv2 for GLES2 backend
* filename: try to load from the defaults folder (don't forget to use complete filename, with ".so" extension). If not found/loaded, default one will be tried.
* /path/to/filename: try to use exact path/filename. If not found/loaded, default one will be tried.
##### LIBGL_DBGSHADERCONV
Log to the console all shaders before and after conversion
* 0 : Default: don't log anything
* 1 : Log Vertex Shader
* 2 : Log Framegent Shader
* 4 : Log Shaders before going to Shaderconv
* 8 : Log Shaders after going to Shaderconv
Note that you can combine (logical or state. So 14 will be only Fragment shader before and after shaderconv)
Note also that if neither Fragment and Vertex are defined, both will be selected. Same for Before and After.
At last, the value "1" will be changed to "15", to log everything.
##### LIBGL_NOPSA
Disable the use of the Precompiled Shader Archive
* 0 : Default: use (and save) the PSA (it's saved on $HOME/.gl4es.psa on linux)
* 1 : Don't use PSA.
##### LIBGL_PSA_FOLDER
Set a custom path for Precompile Shader Archive
* XXXXX : set that path. Archive will be saved at XXXXX/.gl4es.psa
##### LIBGL_USEVBO
Usage of VBO in certain cases. Only for GLES2+. The 2 and 3 mode are experimental and will probably be slower anyway.
* 0 : Disable the use of VBO.
* 1 : Default: Use VBO when possible (for Arrays VBO or glList)
* 2 : Use VBO when possible (and also on `glLockArrays`).
* 3 : Use VBO when possible (and special case on `glLockArrays` for idTech3 engine games).
##### LIBGL_NOES2COMPAT
Don't expose GLX_EXT_create_context_es2_profile extension
* 0 : Extension is there
* 1 : Don't expose the extension: for SDL2, use it with SDL_VIDEO_GL_DRIVER and SDL_VIDEO_EGL_DRIVER to use GLESv2 driver directly
##### LIBGL_NOINTOVLHACK
Disable the hack in shader converter to define overloaded function with int
* 0 : Default: use the hack
* 1 : Don't use it (some driver / PVRCapture don't like it much).
##### LIBGL_GLXNATIVE
Disable the filtering of GLXConfig by NATIVE_TYPE
* 0 : Default, GLX_X_NATIVE_TYPE attribute are taken into account
* 1 : Don't filter GLXConfig by GLX_X_NATIVE_TYPE
##### LIBGL_NOSHADERLOD
Disable GL_EXT_shader_texture_lod
* 0 : Default, use the extension if present
* 1 : Disable the use of the extension (using crude fallback)
##### LIBGL_NORMALIZE
Force normals to be normliazed in FPE
* 0 : Default, don't force normalizations
* 1 : Force normalization on normals on FPE, even when it's disabled (workaround for a bug that prevent colors on Minecraft 1.16+)
###### LIBGL_BLITFB0
Blit to FB 0 force a SwapBuffer
* 0 : Default, don't force a SwapBuffer when glBlitFramebuffer to draw fb0 is used (unless the full FB0 if blitted)
* 1 : Force a SwapBuffer each time glBlitFramebuffer on FB0 is used (can help some windowed Wine games)
###### LIBGL_DEEPBIND
Use RTLD_DEEPBIND when loading EGL and GLES library
* 0 : Default except on PYRA, use RTLD_DEEPBIND when loading EGL/GLES libraries
* 1 : Default only on PYRA, don't use RTLD_DEEPBIND when loading EGL/GLES libraries
+2
View File
@@ -0,0 +1,2 @@
theme: jekyll-theme-hacker
title: GL4ES - The OpenGL driver for GLES Hardware
+84
View File
@@ -0,0 +1,84 @@
/**********************************************************************
*
* Copyright (C) 2009 Texas Instruments Incorporated - http://www.ti.com/
* Copyright(c) 2008 Imagination Technologies Ltd. All rights reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms and conditions of the GNU General Public License,
* version 2, as published by the Free Software Foundation.
*
* This program is distributed in the hope it will be useful but, except
* as otherwise stated in writing, without any warranty; without even the
* implied warranty of merchantability or fitness for a particular purpose.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin St - Fifth Floor, Boston, MA 02110-1301 USA.
*
* The full GNU General Public License is included in this distribution in
* the file called "COPYING".
*
* Contact Information:
* Imagination Technologies Ltd. <gpl-support@imgtec.com>
* Home Park Estate, Kings Langley, Herts, WD4 8LZ, UK
*
******************************************************************************/
#ifndef __BC_CAT_H__
#define __BC_CAT_H__
#include <linux/ioctl.h>
#define BC_FOURCC(a,b,c,d) \
((unsigned long) ((a) | (b)<<8 | (c)<<16 | (d)<<24))
#define BC_PIX_FMT_NV12 BC_FOURCC('N', 'V', '1', '2') /*YUV 4:2:0*/
#define BC_PIX_FMT_UYVY BC_FOURCC('U', 'Y', 'V', 'Y') /*YUV 4:2:2*/
#define BC_PIX_FMT_YUYV BC_FOURCC('Y', 'U', 'Y', 'V') /*YUV 4:2:2*/
#define BC_PIX_FMT_RGB565 BC_FOURCC('R', 'G', 'B', 'P') /*RGB 5:6:5*/
enum BC_memory {
BC_MEMORY_MMAP = 1,
BC_MEMORY_USERPTR = 2,
};
typedef struct BCIO_package_TAG {
int input;
int output;
}BCIO_package;
/*
* the following types are tested for fourcc in struct bc_buf_params_t
* NV12
* UYVY
* RGB565 - not tested yet
* YUYV
*/
typedef struct bc_buf_params {
int count; /*number of buffers, [in/out]*/
int width; /*buffer width in pixel, multiple of 8 or 32*/
int height; /*buffer height in pixel*/
unsigned int fourcc; /*buffer pixel format*/
enum BC_memory type;
} bc_buf_params_t;
typedef struct bc_buf_ptr {
unsigned int index;
int size;
unsigned long pa;
} bc_buf_ptr_t;
#define BCIO_GID 'g'
#define BC_IOWR(INDEX) _IOWR(BCIO_GID, INDEX, BCIO_package)
#define BCIOGET_BUFFERCOUNT BC_IOWR(0) /*obsolete, since BCIOREQ_BUFFERS
return the number of buffers*/
#define BCIOGET_BUFFERPHYADDR BC_IOWR(1) /*get physical address by index*/
#define BCIOGET_BUFFERIDX BC_IOWR(2) /*get index by physical address*/
#define BCIOREQ_BUFFERS BC_IOWR(3)
#define BCIOSET_BUFFERPHYADDR BC_IOWR(4)
#endif
+5
View File
@@ -0,0 +1,5 @@
gl4es for Debian
---------------
-- licheng <shiptux@gmail.com> Fri, 30 Jul 2021 14:53:15 +0800
+7
View File
@@ -0,0 +1,7 @@
gl4es for Debian
---------------
-- licheng <shiptux@gmail.com> Fri, 30 Jul 2021 14:53:15 +0800
+5
View File
@@ -0,0 +1,5 @@
gl4es (1.1.4-1) unstable; urgency=medium
* Initial release
-- licheng <shiptux@gmail.com> Fri, 30 Jul 2021 14:53:15 +0800
+1
View File
@@ -0,0 +1 @@
11
+29
View File
@@ -0,0 +1,29 @@
Source: gl4es
Section: libs
Priority: optional
Maintainer: licheng <shiptux@gmail.com>
Build-Depends: debhelper (>= 11),cmake,libx11-dev
Standards-Version: 4.1.3
Homepage: https://github.com/ptitSeb/gl4es
Package: libgl4es
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Multi-arch: no
Description: GL4ES - OpenGL for GLES Hardware
This is a library provide OpenGL 2.x functionality for GLES2.0 accelerated Hardware
(and of course also support OpenGL 1.5 function, so metimes better than when using
GLES 1.1 backend) There is also support for GLES 1.1 Hardware, emulating OpenGL 1.5,
and some OpenGL 2.x + extensions.
This package contains the shared libraries.
Package: libgl4es-dev
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Multi-arch: no
Description: GL4ES - OpenGL for GLES Hardware
This is a headers provide OpenGL 2.x functionality for GLES2.0 accelerated Hardware
(and of course also support OpenGL 1.5 function, so metimes better than when using
GLES 1.1 backend) There is also support for GLES 1.1 Hardware, emulating OpenGL 1.5,
and some OpenGL 2.x + extensions.
This package contains the development files.
+46
View File
@@ -0,0 +1,46 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: gl4es
Source: https://github.com/ptitSeb/gl4es
Files: *
Copyright: Copyright (c) 2016-2018 Sebastien Chevalier
Copyright (c) 2013-2016 Ryan Hileman
License: Expat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Files: debian/*
Copyright: 2021 licheng <shiptux@gmail.com>
License: GPL-2+
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
.
This package 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 for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
@@ -0,0 +1,3 @@
include/gl4esinit.h usr/include/gl4es/
include/gl4eshint.h usr/include/gl4es/
@@ -0,0 +1,2 @@
lib/libGL.so.1 usr/lib/gl4es/
@@ -0,0 +1,13 @@
fix lintian error
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -6,6 +6,9 @@
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
+set(CMAKE_SKIP_INSTALL_RPATH ON)
+set(CMAKE_SKIP_RPATH ON)
+
link_directories(${CMAKE_LIBRARY_OUTPUT_DIRECTORY})
option(PANDORA "Set to ON if targeting an OpenPandora device" ${PANDORA})
+1
View File
@@ -0,0 +1 @@
fix_lintian_error

Some files were not shown because too many files have changed in this diff Show More