Skip to content

Releases

Zelda II 2P Splitscreen v0.1.0 — Android

Choose a tag to compare

@doctormajid7-ux doctormajid7-ux released this 28 Sep 01:36

The first Android release of Zelda II (the z2rs port): a portrait two-player split screen, a sound bug fixed, and HD packs that finally load on a phone.

Two players share one phone — the picture is drawn twice, the top copy upside down, and each player gets their own on-screen pad. Your ROM and your HD pack stay yours: neither is redistributed here.


📱 Install

Download z2rs-2p-splitscreen-0.1.0-debug.apk (866 KB) from the assets below, transfer it to the phone and open it. Android will ask you to allow installing from an unknown source. From a computer:

adb install -r z2rs-2p-splitscreen-0.1.0-debug.apk
  • Android 7.0 (API 24) or newer.
  • A debug build, signed with the debug key: fine for playing, resign it to publish on a store.
f5d9e5f749f2ba782e72d90ce5f8def9e7c2b6ecc2599d1bf8b271cdf104d4ed  z2rs-2p-splitscreen-0.1.0-debug.apk  (SHA-256)

🎮 For players

Your ROM. It is in neither the APK nor the repository: it has to come from your own cartridge. On the first launch the Game ROM card asks you to pick your .nes file. It is checked by hash (the USA dump, CRC32 BA322865) and stays on your device — the next launch reloads it by itself and the card reads "ROM loaded". A rejected file is reported, with the hash the app expects.

Two players, one screen. Tick Local co-op: the screen is cut in two, the top copy is rotated 180°, and a second pad appears at the top — the two players face each other, one per half. The phone stays in portrait while the mode is on, and ✕ 1 player (halfway down the right edge) takes you back to playing alone.

Placing the controls. Pad ↑ and Pad ↓ (Player card) move both pads at once, each pulling away from its own edge: handy for a thick case, a rounded corner or a gesture bar. One step is 5% of the screen height, up to 30%, and the setting is remembered. Hide touch pad hides them entirely.

Sound. It starts on the first touch of a pad (or with Enable audio) and it keeps running: an emulation bug used to silence the output for good on the 44,100 → 48,000 Hz sample-rate switch, which left the game mute on many phones. Fixed.

HD graphics (optional). If you own an HD pack, open the HD graphics pack card, tap Pack folder on this device… and pick the folder that holds pack.json (the pack can stay on the SD card). Use original art brings back the original graphics. Nothing is copied into the app.

Name and icon. The app is called "Zelda II 2P Splitscreen" and carries its own icon, taken from icon1.

What is new

  • Mirrored two-player split screen (portrait, top half upside down, a second touch pad, orientation locked) — also available on desktop, where it can be turned off with --split2p off.
  • Sound fixed: a sample-rate change mid-game no longer silences the APU for good.
  • HD graphics enabled in the app, with a pack-folder picker and a way back to the original art.
  • Moving the controls (Pad ↑ / Pad ↓), on top of Hide touch pad.
  • The Game ROM card says what happened ("ROM loaded" / "ROM rejected") instead of claiming "No ROM is provided" forever.
  • Card order: Status before Player.
  • Double-tap no longer toggles fullscreen (the Fullscreen button and F11 still do).
  • The ROM is no longer embedded in the APK.

🔧 Technical details

For advanced users, and for assistants picking this repository up. The full documentation is in ANDROID.md.

Architecture. The Android shell reuses the web frontend: there is no second emulator. crates/z2-web/site/ (the page, app.js, the audio worklet, pkg/ wasm) is copied into the assets and served from https://appassets.androidplatform.net/ by shouldInterceptRequest — not from file://, which can neither instantiate wasm nor keep IndexedDB or an AudioWorklet. The Content-Type of .wasm is exactly application/wasm, otherwise instantiateStreaming fails. The request policy and the orientation are the only things the shell owns; the split, the doubled canvas and the second pad belong to the page.

route serves
/* the APK assets (assets/site/…)
/_rom/zelda2.nes the chosen ROM, in the app's private storage (falling back to an asset if a build embedded one)
/_hdp/… the tree of the chosen HD pack folder, resolved by document name

Split screen. Core: crates/z2-ppu/src/split2p.rs — height_multiplier, out_len, duplicate_rotated, duplicate_rotated_in_place; the frame is presented W × 2H, with the top copy rotated 180°. Native frontend: DisplaySettings::split_2p, doubled memory in Display::present, the --split2p on|off flag, the split_2p config key (true by default in local co-op, refused online). Web frontend: WebEmu::split_2p / split_2p_enable() / split_2p_enabled(), applied after render_single_frame(), with frame_height() / logical_height() following the multiplier. Page: applySplit() = split wanted and local co-op and no network session; #room.split2p (the TV pane leaves the 4:3 cabinet and fixes itself fullscreen), #touchpad2 with transform: rotate(180deg), coordinates inverted in dpadBits() and pointers assigned by pointerId. Orientation: z2.ext.split.on() polled every 250 ms → SCREEN_ORIENTATION_PORTRAIT or FULL_SENSOR, with configChanges in the manifest so a rotation does not recreate the WebView (and kill the game). Turning it off: ?split=0 or the "Portrait split" checkbox.

The audio fix, in one line. Apu::set_sample_rate recomputed the resampler's mark from the total cycle count: on the 44,100 → 48,000 Hz switch (what the first touch triggers when the phone's AudioContext runs at 48 kHz) the emit loop caught up the accumulated lag in one go and emitted a sample with an empty accumulator — 0.0 / 0.0 = NaN, NaN stored in the high-pass filter state, permanent digital silence (peak: 0 on the worklet side). The fix rebases samples_emitted on the new mark and guards the division by zero. Regression test: title_music_survives_a_mid_game_rate_switch (skips itself without Z2_ROM).

HD pack with no reachable folder. A WebView cannot do webkitdirectory: its picker hands back bare file names, so sheet-01.png can never match the sheets/sheet-01.png the manifest asks for ("cannot read … not among the provided files"). On the app's own origin the page hides the original picker and shows Pack folder on this device…: it navigates to z2rs://hd-pack, MainActivity opens ACTION_OPEN_DOCUMENT_TREE and mounts the tree under /_hdp/ (descending by name through DocumentsContract). The page waits for _hdp/pack.json, reads the files the manifest names, and loads the pack through the same wasm path as the desktop (hd_pack_begin / hd_pack_add_file / hd_pack_commit). Everywhere else (hosted site, desktop) the original folder picker is untouched.

Placing the pads. Pad ↑ / Pad ↓ set a --pad-shift CSS variable (in vh, 0–30 in steps of 5), applied to #touchpad as translateY(calc(-1 * var(--pad-shift))) and to #touchpad2 before its rotation (rotate(180deg) translateY(calc(-1 * var(--pad-shift))), which moves it down by the same amount); #touchSpacer grows by the same amount. Stored in localStorage['z2rs.padShift'], exposed to QA as z2.ext.touch.padShift(vh) and padShiftVh().

Building. The web bundle must include the HD feature, otherwise the "HD graphics pack" card stays inert:

RUSTUP_TOOLCHAIN=stable wasm-pack build crates/z2-web --target web --out-dir site/pkg --features hd

cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug   # a JDK, not a JRE

minSdk 24, targetSdk 35, applicationId org.z2rs.app, no external dependencies (not even AndroidX): only the android.* APIs and the platform WebView. Three Gradle tasks, hooked to preBuild and to the merge*Assets tasks: prepareSite (copies the site, fails with the command to run if the wasm is missing), stageRom (Z2_APK_ROM=/path/to/dump) and stageHdPack (Z2_APK_HDPACK=/path/to/pack) — both of them opt-in: by default the APK holds neither a ROM nor a pack, and any copy left behind by a previous build is deleted.

Tests. cargo +stable test --workspace: everything passes except perf_reports_speed_with_regression_floor (z2-verify/tests/oracle_tetanes.rs), a debug performance floor (0.7x instead of 1x on a loaded machine), unrelated to this work — z2-verify depends on neither z2-apu nor z2-web, and that test did not even run before, for lack of Z2_ROM. cargo +stable fmt --all --check: clean. clippy clean on z2-apu / z2-web; the six warnings that remain come from the chunks_exact_to_as_chunks lint (clippy 1.98) in files this work did not touch.

⚖️ Legal

No ROM and no HD pack are supplied, neither here nor in the APK (see LEGAL.md §1). Use your own cartridge dump. If you use a community or Patreon HD pack, respect its license: the project's own pack is licensed for personal use and must not be redistributed.

⚠️ Known limitations

  • The HD pack folder has to be picked on every launch: the grant is requested as persistent, but the mount is per session. Nothing is copied into the app (on purpose).
  • The Game ROM card only applies to the app's own origin; on the hosted site the usual file picker is still in place.
  • Debug build only, for now.