Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ jobs:
name: host_web
path: null0_web.zip

host_android:
needs: generated
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
# the runner has the Android SDK; gradle installs the pinned NDK and CMake
- name: Build Android Host
run: npm run host:android
- name: Rename APK
run: cp android/app/build/outputs/apk/release/app-release.apk null0_android.apk
- name: Upload Android Host artifact
uses: actions/upload-artifact@v4
with:
name: host_android
path: null0_android.apk

# Everything generated from api/*.yml - the bindings, host.c, API.md,
# api.json, templates - has to be committed in sync with its generator.
# Nothing rebuilds it at release time, so a stale file ships as-is: v0.0.13
Expand Down Expand Up @@ -424,6 +446,7 @@ jobs:
- host_mac
- host_windows
- host_web
- host_android
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
45 changes: 42 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ it the null0 API as wasm imports, and calls exported callbacks (`update`,
`keyDown`, ...) as the game runs. Carts can be written in 18 languages, so most
work here is "teach another language to talk to the same ABI".

Hosts: native (raylib + WAMR) and web (emscripten + browser WebAssembly). The
README mentions libretro, but there is no libretro code in the tree yet.
Hosts: native (raylib + WAMR), android (the same native host, built by the
NDK - see "The android host" below) and web (emscripten + browser
WebAssembly). The README mentions libretro, but there is no libretro code in
the tree yet.

## Repo map

Expand All @@ -27,6 +29,7 @@ README mentions libretro, but there is no libretro code in the tree yet.
| `tools/docker/` | One Dockerfile + one `build_<lang>.sh` per language, plus baked interpreter sources. |
| `carts/<lang>/` | The language's null0 header/bindings + example carts. |
| `host/src/` | The engine. Mostly hand-written; `host.c` is generated. |
| `android/` | Gradle project for the android host: launcher + NativeActivity wrapper (Java). |
| `webroot/` | The web player (`null0.js` loads the emscripten host, then the cart). |
| `build/`, `wbuild/` | Native and web build output (gitignored). |

Expand Down Expand Up @@ -58,7 +61,8 @@ and you race. Phase one is `gen:host` + `gen:cart_*` in parallel, phase two is

Hand-written host code lives in `host_header.h` (helpers, memory copying,
`add_image`/`add_font`/`add_sound`), `host.h` (the `HOST_FUNCTION` macro),
`fs.c`, `wasi_physfs.h`, `cart_wamr.c`, `cart_emscripten.c`, `main.c`.
`fs.c`, `wasi_physfs.h`, `cart_wamr.c`, `cart_emscripten.c`, `main.c`,
`android.c`.

### How `host.c` is generated

Expand Down Expand Up @@ -247,6 +251,41 @@ If you change any of this, test it **in a browser**, not just natively - and
test with more than one checkbox on screen. `carts/c/gui` has several
deliberately, because a single one of each passes even when identity is broken.

## The android host

`android/` is a gradle project whose native half is the root `CMakeLists.txt`
(`host` becomes `libnull0.so` when `ANDROID` is set). raylib runs as
`PLATFORM_ANDROID` inside a NativeActivity; everything android-specific in C
is in `host/src/android.c`, hooked in with `#ifdef __ANDROID__`.

- **The cart runs in its own process** (`android:process=":cart"`), killed
when the cart ends. The host keeps its state in globals (WAMR runtime,
handle vectors, physfs), so it can only ever run one cart per process.
- **raylib doesn't handle rotation or a resized surface.** Orientation is fixed
per activity (`CartActivityLandscape`/`Portrait`), and fullscreen is set
before the native window exists. `main.c` redefines `InitWindow` to
`InitWindow(0, 0, ...)` on android - pntr_app's 2x window would otherwise be
letterboxed by raylib, leaving nowhere to draw the controller.
- **The on-screen controller is drawn after pntr_app renders** (`main.c` routes
`EndDrawing` through `null0_android_draw_controller`) and pressed through
`pntr_app_process_event`, so callbacks and polling both see it, as player 0.
- **Touches are mice with no hover.** pntr_app only moves its pointer by
deltas, and microui (the gui) needs the control *and* its window hovered on
earlier frames. `null0_android_filter_event` moves the pointer on touch-down
and replays the press two frames later. Touches that start on the
controller never reach the cart as mouse events.
- **WAMR's hardware bound-checks are off on android** (`Findwamr.cmake`): its
stack guard-page probe SIGSEGVs on NativeActivity's thread.
- `Findwamr.cmake` picks the platform from `CMAKE_SYSTEM_NAME`, not the host
system, so it's right when cross-compiling.
- `timespec_get` only exists from API 29; minSdk is 24.
- stdout/stderr are piped to logcat (tag `null0`).

Testing: `adb logcat -s null0` for output, `adb shell input motionevent
DOWN x y` / `UP` to hold a touch (a plain `input tap` can land in a single
frame), `adb shell input gamepad keyevent KEYCODE_BUTTON_A` for a physical pad,
and `adb exec-out screencap -p > shot.png` to look at it.

## Adding a cart language

There are two shapes. Copy the closest existing one instead of inventing.
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The basic idea here is a small game-engine where you make "carts", in whatever language you like, which will run on native, libretro, or web.
The basic idea here is a small game-engine where you make "carts", in whatever language you like, which will run on native, android, libretro, or web.

[Read the docs](https://notnull.games/null0) to find out more.

Expand Down Expand Up @@ -112,6 +112,22 @@ investigating - not just "not gotten to yet". Full details in
- name your cart main.null0, and rename null0 (for each platform) to whatever you want
- you can merge them: `cat null0 mygame.null0 > mygame && chmod +x mygame`

### android

`npm run host:android` builds an apk (`android/app/build/outputs/apk/`). It
needs the Android SDK and JDK 17 or newer; gradle installs the NDK and CMake it wants.
Open a `.null0` from the app (or from a file manager/browser download) and it
is kept in the app's list for next time.

- On-screen SNES-style controller (dpad, A/B/X/Y, L/R, select/start), with
multi-touch, dpad diagonals, and B+Y style "between two buttons" presses.
Turn it off in the app's settings if you only use a real controller.
- Physical gamepads work, and the left analog stick also drives the dpad.
- Landscape (controller beside the game) or portrait (controller below it).
- Back quits the cart. `printf` from carts shows up in `adb logcat -s null0`.

To test in an emulator, start one (`emulator -avd <name>`, or Android Studio's Device Manager), then `adb install -r android/app/build/outputs/apk/release/app-release.apk`, `adb push mygame.null0 /sdcard/Download/`, and use "Open cart…" in the app.

## todo/ideas

You can currently do all of these things yourself, or use a library for your language, but I think it would be cool to abstract them into the engine:
Expand Down
7 changes: 7 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.gradle/
.cxx/
build/
app/build/
app/.cxx/
local.properties
*.apk
58 changes: 58 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import groovy.json.JsonSlurper

plugins {
id("com.android.application")
}

// keep the app version in step with the engine's (package.json)
val pkg = JsonSlurper().parse(rootProject.file("../package.json")) as Map<*, *>
val engineVersion = pkg["version"] as String
val engineVersionCode = engineVersion.split(".").fold(0) { acc, part -> acc * 100 + part.toInt() }

android {
namespace = "games.notnull.null0"
compileSdk = 34
ndkVersion = "27.2.12479018"

defaultConfig {
applicationId = "games.notnull.null0"
minSdk = 24
targetSdk = 34
versionCode = engineVersionCode
versionName = engineVersion

ndk {
// arm64 for devices, x86_64 for the emulator
abiFilters += listOf("arm64-v8a", "x86_64")
}

externalNativeBuild {
cmake {
arguments += listOf("-DCMAKE_BUILD_TYPE=Release", "-DANDROID_STL=none")
targets += "host"
}
}
}

// the host is the same CMake project the desktop and web builds use
externalNativeBuild {
cmake {
path = file("../../CMakeLists.txt")
version = "3.31.6"
}
}

buildTypes {
release {
isMinifyEnabled = false
// no release keystore in the repo: sign with the debug key so the apk
// installs. swap this for a real signingConfig to publish
signingConfig = signingConfigs.getByName("debug")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
77 changes: 77 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-feature android:glEsVersion="0x00020000" android:required="true" />
<!-- works with a touchscreen, a gamepad, or both -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />

<application
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:isGame="true"
android:hasCode="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">

<!-- pick a cart, settings -->
<activity
android:name=".LauncherActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- open a .null0 from a file manager, browser download, etc -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/wasm" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.null0" />
<data android:pathPattern=".*\\..*\\.null0" />
<data android:pathPattern=".*\\..*\\..*\\.null0" />
</intent-filter>
</activity>

<!--
the engine (libnull0.so) in a NativeActivity. it runs in its own process,
which is killed when the cart ends: the engine keeps its state in
globals, so every cart gets a fresh one.

raylib doesn't handle rotation, so orientation is fixed per activity -
the launcher picks which one
-->
<activity
android:name=".CartActivityLandscape"
android:exported="false"
android:process=":cart"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|smallestScreenSize"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="null0" />
</activity>
<activity
android:name=".CartActivityPortrait"
android:exported="false"
android:process=":cart"
android:screenOrientation="sensorPortrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|smallestScreenSize"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="null0" />
</activity>
</application>
</manifest>
92 changes: 92 additions & 0 deletions android/app/src/main/java/games/notnull/null0/CartActivity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package games.notnull.null0;

import android.app.NativeActivity;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;

// runs a cart: the engine (libnull0.so) is a NativeActivity, and android.c
// calls the getters below over JNI to find out what to run
public class CartActivity extends NativeActivity {
static final String EXTRA_CART = "cart";
static final String EXTRA_CONTROLLER = "controller";

static Intent intent(Context context, String cartPath, boolean controller, boolean portrait) {
Class<?> activity = portrait ? CartActivityPortrait.class : CartActivityLandscape.class;
return new Intent(context, activity)
.putExtra(EXTRA_CART, cartPath)
.putExtra(EXTRA_CONTROLLER, controller);
}

@Override
protected void onCreate(Bundle savedInstanceState) {
// go fullscreen before the native window exists - raylib sizes itself
// once, and doesn't handle the surface changing size later
fullscreen();
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
fullscreen();
}
}

@Override
protected void onDestroy() {
super.onDestroy();
// the engine keeps its state in globals, so this process can't run a
// second cart. it's a separate process (android:process=":cart"), so
// just end it - the launcher is unaffected
Process.killProcess(Process.myPid());
}

@SuppressWarnings("deprecation")
private void fullscreen() {
Window window = getWindow();
// the insets controller hangs off the decor view, which doesn't exist yet
// in onCreate unless asked for
window.getDecorView();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false);
WindowInsetsController controller = window.getInsetsController();
if (controller != null) {
controller.hide(WindowInsets.Type.systemBars());
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
// the LAYOUT_ flags keep the surface full-size whether the bars are
// showing or not
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}

// called from native (android.c)
public String getCartPath() {
return getIntent().getStringExtra(EXTRA_CART);
}

// called from native (android.c)
public boolean getShowController() {
return getIntent().getBooleanExtra(EXTRA_CONTROLLER, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package games.notnull.null0;

// a cart, locked to landscape (see AndroidManifest.xml)
public class CartActivityLandscape extends CartActivity {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package games.notnull.null0;

// a cart, locked to portrait (see AndroidManifest.xml)
public class CartActivityPortrait extends CartActivity {}
Loading
Loading