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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/android-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ jobs:

- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platform-tools'

- name: Install Android SDK packages
shell: bash
Expand Down Expand Up @@ -275,7 +277,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: ${{ env.BANJO_ANDROID_PRIVATE_INPUTS_REPO }}
ssh-key: ${{ secrets.BANJO_ANDROID_PRIVATE_INPUTS_SSH_KEY }}
token: ${{ secrets.BANJO_ANDROID_PRIVATE_INPUTS_TOKEN || github.token }}
path: extra
persist-credentials: false

Expand Down
374 changes: 366 additions & 8 deletions android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions android/patches/0001-plume-vulkan-null-pipeline-guard.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp
index 7d30360..b4ee03b 100644
--- a/plume_vulkan.cpp
+++ b/plume_vulkan.cpp
@@ -3033,23 +3033,31 @@ namespace plume {
}

void VulkanCommandList::setPipeline(const RenderPipeline *pipeline) {
- assert(pipeline != nullptr);
+ if (pipeline == nullptr) {
+ return;
+ }

const VulkanPipeline *interfacePipeline = static_cast<const VulkanPipeline *>(pipeline);
switch (interfacePipeline->type) {
case VulkanPipeline::Type::Compute: {
const VulkanComputePipeline *computePipeline = static_cast<const VulkanComputePipeline *>(interfacePipeline);
- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline->vk);
+ if (computePipeline != nullptr && computePipeline->vk != VK_NULL_HANDLE) {
+ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline->vk);
+ }
break;
}
case VulkanPipeline::Type::Graphics: {
const VulkanGraphicsPipeline *graphicsPipeline = static_cast<const VulkanGraphicsPipeline *>(interfacePipeline);
- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline->vk);
+ if (graphicsPipeline != nullptr && graphicsPipeline->vk != VK_NULL_HANDLE) {
+ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline->vk);
+ }
break;
}
case VulkanPipeline::Type::Raytracing: {
const VulkanRaytracingPipeline *raytracingPipeline = static_cast<const VulkanRaytracingPipeline *>(interfacePipeline);
- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, raytracingPipeline->vk);
+ if (raytracingPipeline != nullptr && raytracingPipeline->vk != VK_NULL_HANDLE) {
+ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, raytracingPipeline->vk);
+ }
break;
}
default:
16 changes: 16 additions & 0 deletions android/patches/0002-recompui-android-modern-defaults.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
diff --git a/recompui/src/config/ui_config_tab_graphics.cpp b/recompui/src/config/ui_config_tab_graphics.cpp
index c944d28..a843f03 100644
--- a/recompui/src/config/ui_config_tab_graphics.cpp
+++ b/recompui/src/config/ui_config_tab_graphics.cpp
@@ -282,7 +282,11 @@ namespace recompui {
"Resolution",
"Sets the output resolution of the game. <recomp-color primary>Original</recomp-color> matches the game's original 240p resolution. <recomp-color primary>Original 2x</recomp-color> will render at 480p. <recomp-color primary>Auto</recomp-color> will scale based on the game window's resolution.",
resolution_options,
+#if defined(__ANDROID__)
+ ultramodern::renderer::Resolution::Original2x
+#else
ultramodern::renderer::Resolution::Auto
+#endif
);
{
config.add_option_change_callback(
23 changes: 21 additions & 2 deletions src/game/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ static void add_general_options(recomp::config::Config &config) {
"Analog Camera",
"Enables the analog camera.",
analog_cam_mode_options,
banjo::AnalogCamMode::Off
banjo::AnalogCamMode::On
);
config.add_number_option(
banjo::configkeys::general::analog_camera_sensitivity,
Expand All @@ -55,7 +55,7 @@ static void add_general_options(recomp::config::Config &config) {
config.add_option_hidden_dependency(
banjo::configkeys::general::analog_camera_sensitivity,
banjo::configkeys::general::analog_cam_mode,
banjo::AnalogCamMode::Off
banjo::AnalogCamMode::On
);
static EnumOptionVector camera_invert_mode_options = {
{banjo::CameraInvertMode::InvertNone, "InvertNone", "None"},
Expand Down Expand Up @@ -177,6 +177,24 @@ static void add_graphics_options(recomp::config::Config &config) {
static void set_control_defaults() {
using namespace recompinput;

#if defined(__ANDROID__)
// On Android handhelds, map Z (Crouch) to both Left Trigger (LT/ZL) and Left Bumper (LB/L1)
set_default_mapping_for_controller(
GameInput::Z,
{
InputField::controller_analog(SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_TRIGGERLEFT, true),
InputField::controller_digital(SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_LEFTSHOULDER)
}
);

// Left shoulder is dedicated to Crouch; keep C_DOWN on right analog stick
set_default_mapping_for_controller(
GameInput::C_DOWN,
{
InputField::controller_analog(SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_RIGHTY, true)
}
);
#else
// Left shoulder -> C Down | Backwards eggs / zoom out
set_default_mapping_for_controller(
GameInput::C_DOWN,
Expand All @@ -185,6 +203,7 @@ static void set_control_defaults() {
InputField::controller_digital(SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_LEFTSHOULDER)
}
);
#endif

// Right shoulder -> C Up | Forwards eggs / first person
set_default_mapping_for_controller(
Expand Down
19 changes: 19 additions & 0 deletions tools/ci/prepare_android_generated_sources.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ MSG
cp -a "$PRIVATE_INPUTS_DIR"/* .
}

apply_adreno_plume_patch() {
local patch_file="${PWD}/android/patches/0001-plume-vulkan-null-pipeline-guard.patch"
if [[ -f "$patch_file" ]]; then
echo "Applying Adreno 650 null pipeline guard patch to plume_vulkan.cpp..."
git -C "${PWD}/lib/rt64/src/contrib/plume" apply --ignore-whitespace "$patch_file" || true
fi
}

apply_recompui_patch() {
local patch_file="${PWD}/android/patches/0002-recompui-android-modern-defaults.patch"
if [[ -f "$patch_file" ]]; then
echo "Applying modern defaults patch to recompui..."
git -C "${PWD}/lib/RecompFrontend" apply --ignore-whitespace "$patch_file" || true
fi
}

apply_adreno_plume_patch
apply_recompui_patch

if [[ "$MODE" == "probe" ]]; then
echo "Probe build selected; generated game sources are not required."
exit 0
Expand Down
137 changes: 137 additions & 0 deletions tools/test_android_rom_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Offline test suite for Android compressed ROM ZIP import and version inspection rules in BanjoSDLActivity.

Verifies that:
1. Pure .z64 files are passed through without modification.
2. Zipped .z64 files are identified, safely extracted to .z64, and the .zip is purged.
3. Zipped .n64 files are identified, safely extracted, and prioritized.
4. Archives without valid ROM entries are gracefully rejected.
5. ROM version detection identifies authentic v1.0 (Rev 0) and flags Rev A (v1.1) and Rev B (v1.2).
"""
from __future__ import annotations

import io
import os
import shutil
import tempfile
import zipfile
from pathlib import Path


def extract_rom_if_compressed_mock(file_path: Path, import_dir: Path) -> Path | None:
is_zip = file_path.name.lower().endswith(".zip")
if not is_zip:
with open(file_path, "rb") as f:
header = f.read(4)
if header == b"PK\x03\x04":
is_zip = True

if not is_zip:
return None

target_entry_name = None
fallback_candidate = None
with zipfile.ZipFile(file_path, "r") as z:
for info in z.infolist():
if info.is_dir():
continue
name = info.filename
lower = name.lower()
if lower.endswith(".z64"):
target_entry_name = name
break
elif lower.endswith(".n64") or lower.endswith(".v64"):
if target_entry_name is None:
target_entry_name = name
elif info.file_size >= 11 * 1024 * 1024:
if fallback_candidate is None:
fallback_candidate = name
if target_entry_name is None:
target_entry_name = fallback_candidate

if target_entry_name is None:
return None

entry_stem = Path(target_entry_name).name
safe_name = entry_stem
if not (safe_name.lower().endswith((".z64", ".n64", ".v64"))):
safe_name += ".z64"

destination = import_dir / safe_name
with zipfile.ZipFile(file_path, "r") as z:
with z.open(target_entry_name) as source, open(destination, "wb") as target:
shutil.copyfileobj(source, target)

return destination


def inspect_rom_version_mock(file_path: Path) -> tuple[bool, int, str]:
if not file_path.is_file() or file_path.stat().st_size < 64:
return (False, -1, "Invalid")
with open(file_path, "rb") as f:
f.seek(0x3B)
header = f.read(5)
if header[:4] == b"NBKE":
ver = header[4]
if ver == 0:
return (True, 0, "v1.0 (Rev 0)")
elif ver == 1:
return (False, 1, "Revision A (v1.1)")
elif ver == 2:
return (False, 2, "Revision B (v1.2)")
return (False, ver, f"Version 0x{ver:02x}")
return (False, -1, "Not Banjo")


def test_rom_extraction_and_versioning():
temp_dir = Path(tempfile.mkdtemp(prefix="test_rom_zip_"))
try:
import_dir = temp_dir / "rom-imports"
import_dir.mkdir()

# Test 1: Uncompressed ROM passthrough
raw_rom = temp_dir / "Banjo.z64"
raw_rom.write_bytes(b"\x80\x37\x12\x40" + b"\x00" * 1000)
res1 = extract_rom_if_compressed_mock(raw_rom, import_dir)
assert res1 is None, "Uncompressed ROM should return None from extract_rom_if_compressed"

# Test 2: Zipped .z64 extraction
zip_rom = temp_dir / "Banjo-Kazooie (USA) (Rev A).n64.zip"
with zipfile.ZipFile(zip_rom, "w") as z:
z.writestr("Banjo-Kazooie (USA) (Rev A).z64", b"\x80\x37\x12\x40" + b"\xaa" * 16777212)

res2 = extract_rom_if_compressed_mock(zip_rom, import_dir)
assert res2 is not None, "Failed to extract zipped ROM"
assert res2.name == "Banjo-Kazooie (USA) (Rev A).z64"
assert res2.read_bytes()[:4] == b"\x80\x37\x12\x40"
assert res2.stat().st_size == 16777216

# Test 3: Zipped archive with nested readme
zip_multi = temp_dir / "GameWithReadme.zip"
with zipfile.ZipFile(zip_multi, "w") as z:
z.writestr("readme.txt", "This is a readme")
z.writestr("subfolder/game.n64", b"\x37\x80\x40\x12" + b"\xbb" * 12000000)

res3 = extract_rom_if_compressed_mock(zip_multi, import_dir)
assert res3 is not None
assert res3.name == "game.n64"
assert res3.read_bytes()[:4] == b"\x37\x80\x40\x12"

# Test 4: ROM version detection
rom_v10 = temp_dir / "Banjo-Kazooie (USA).z64"
rom_v10.write_bytes(b"\x00" * 0x3B + b"NBKE\x00" + b"\x00" * 100)
valid, ver, name = inspect_rom_version_mock(rom_v10)
assert valid is True and ver == 0 and name == "v1.0 (Rev 0)", f"Failed v1.0 check: {name}"

rom_reva = temp_dir / "Banjo-Kazooie (USA) (Rev A).z64"
rom_reva.write_bytes(b"\x00" * 0x3B + b"NBKE\x01" + b"\x00" * 100)
valid, ver, name = inspect_rom_version_mock(rom_reva)
assert valid is False and ver == 1 and name == "Revision A (v1.1)", f"Failed Rev A check: {name}"

print("All ROM ZIP extraction and version inspection fixture tests passed successfully!")
finally:
shutil.rmtree(temp_dir)


if __name__ == "__main__":
test_rom_extraction_and_versioning()
Loading