11 Commits

Author SHA1 Message Date
ml 4be350d758 dist.sh builds versioned, signed artifacts for both apps from a git tag
release / build (push) Has been cancelled
(semver drives Android versionCode/versionName and package.json). The
build-image/ Dockerfile pins the same toolchain for a portable,
containerized Gitea Actions runner (build-image.sh builds and pushes
it) so releases don't depend on any one machine's local setup.
Pushing vX.Y.Z now builds and publishes a Gitea Release automatically.
2026-07-02 13:07:52 +02:00
ml 73c6c90c8d Adding a README 2026-07-01 16:10:37 +02:00
ml 3705935ca4 Adding F-Droid publishing 2026-06-27 12:13:48 +02:00
ml eee068a500 Wire keystore signing config; trim publish.md
- Read keystore.properties at Gradle config time; wire signingConfig into
  the release build type (degrades gracefully when the file is absent)
2026-06-27 12:04:55 +02:00
ml daefed88b1 Add Fetch Disks button and Anleitung copy-protection viewer
- Cross-compile nibtools for Android NDK; download all 8 episode disks
  from the Internet Archive C64 Preservation Project as NBZ and convert
  to G64 via JNI (throwaway-pthread to contain nibconv's exit() calls)
- Bundle manual transcription as APK asset; 📖 button opens scrollable
  viewer with Seite→Absatz→Zeile→Wort lookup hierarchy documented
2026-06-27 10:36:15 +02:00
ml aaa6e772a7 Replace bundled ROM extraction with runtime import/download flow
Commodore ROMs are copyrighted and can no longer ship inside the APK.
MainActivity now detects missing ROMs on first launch and blocks
startup with a dialog offering two paths: pick files via the system
file picker, or download the official VICE 3.8 tarball and extract the
four ROMs from it client-side (minimal USTAR reader, no extra deps).

- Drop the extractViceRoms Gradle task and asset bundling; add
  res/extract_roms.sh for local sideloading during development instead
- gitignore keystores/keystore.properties ahead of a signed release
- Move architecture.md into docs/, refresh it for the screen-mirroring
  and watch-input additions, and add accompanying Mermaid diagrams
- Add docs/debugging.md and docs/publish.md (Play Store release notes,
  ROM-import compliance rationale)
2026-06-25 09:27:19 +02:00
ml 89c99b50e2 Add screen mirroring, watch input, splash screen, and architecture docs
Mirrors the C64 text screen to the Pebble watch (with game-specific
  umlaut handling), adds a SELECT-triggered key wheel for sending
  navigation input back to the emulator, shows a splash screen on watch
  launch, and documents the resulting end-to-end architecture.
2026-06-21 19:15:19 +02:00
ml 463c85e62f Fix snapshot save/load: route both through CPU trap to sync registers
machine_write_snapshot reads maincpu_regs.pc to record the current
  program counter. When called directly from video_canvas_refresh,
  maincpu_regs is stale — the CPU loop keeps registers as stack-local
  variables and only syncs them to maincpu_regs inside DO_INTERRUPT via
  EXPORT_REGISTERS(). The snapshot therefore recorded the wrong PC.

  On restore (already trap-based), the wrong PC was faithfully written
  back into reg_pc via IMPORT_REGISTERS(), causing the CPU to resume at
  a bad address. The game screen looked correct (memory/VIC/SID state
  all restored) but the CPU never reached the input-polling code, so
  button presses had no effect.

  Fix: route machine_write_snapshot through interrupt_maincpu_trigger_trap
  so DO_INTERRUPT(IK_TRAP) calls EXPORT_REGISTERS() before the trap
  function, capturing the true current PC before the snapshot is written.
2026-06-17 20:50:51 +02:00
ml b9153ac973 Improve snapshop saving and loading 2026-06-14 10:21:37 +02:00
ml 0f814eb134 Adding a save state panel 2026-06-14 10:04:59 +02:00
ml 755e7758e4 Add hero save editor with D64 format documentation
Pencil button next to each hero disk opens an editor dialog that parses
  the D64 directly, lists all PRG save files in a spinner, and lets the
  user adjust all nine stats (Vitalität, grade, six percentage stats) with
  +/− buttons. Changes to multiple heroes in one session are written back
  together on Save.

  PETSCII decoding now distinguishes unshifted (0x41–0x5A → lowercase) from
  shifted (0xC1–0xDA → uppercase) so names display in natural mixed case
  (e.g. "Mark" instead of "MARK").

  D64 sector layout helpers extracted to class level so createBlankD64 and
  the new editor share the same offset function. Save format documented in
  res/HELD_D64_FORMAT.md. Editor strings localised in EN/DE.
2026-06-14 09:46:17 +02:00
80 changed files with 3307 additions and 351 deletions
+56
View File
@@ -0,0 +1,56 @@
name: release
# Push a tag matching vX.Y.Z (e.g. v1.2.3) to build and publish a release.
# The tag drives the version — nothing to bump in source files beforehand.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
# Needed to create the release and upload assets with GITEA_TOKEN below,
# regardless of this instance's default Actions permission mode.
permissions:
contents: write
# Must match the label your self-hosted act_runner registers with.
runs-on: self-hosted
container:
# :latest — always the most recently pushed build-image.sh output.
# Pin to a specific tag here (see build-image/VERSION) if you need a
# release build to be reproducible against an exact toolchain image.
image: cr.ladkau.de/schwert-und-magie/builder:latest
steps:
- uses: actions/checkout@v4
- name: Write release signing credentials
run: |
set -euo pipefail
echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d \
> SchwertUndMagieOnPebbleCompanionApp/release.keystore
printf '%s\n' "${{ secrets.RELEASE_KEYSTORE_PROPERTIES }}" \
> SchwertUndMagieOnPebbleCompanionApp/keystore.properties
- name: Build artifacts
run: |
set -euo pipefail
VERSION="${{ gitea.ref_name }}"
VERSION="${VERSION#v}"
VERSION="$VERSION" ./dist.sh
- name: Create release and upload artifacts
run: |
set -euo pipefail
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
RELEASE_ID="$(curl -sf -X POST "$API/releases" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${{ gitea.ref_name }}\", \"name\": \"${{ gitea.ref_name }}\"}" \
| jq -r .id)"
for f in dist/*; do
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "$AUTH" -F "attachment=@$f"
done
+13
View File
@@ -20,5 +20,18 @@ local.properties
/SchwertUndMagieOnPebbleCompanionApp/app/.cxx /SchwertUndMagieOnPebbleCompanionApp/app/.cxx
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src /SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs /SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-src
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-libs
/SchwertUndMagieOnPebbleWatchApp/build /SchwertUndMagieOnPebbleWatchApp/build
/dist
# Release signing — never commit keystores or their credentials.
# See docs/publish.md.
*.jks
*.keystore
keystore.properties
# Container registry push credentials for build-image.sh. See docs/publish.md.
registry.env
+40 -4
View File
@@ -101,9 +101,13 @@ The NDK must be installed: **Android Studio → SDK Manager → SDK Tools → ND
After the first successful build, subsequent builds skip the VICE compilation step entirely (outputs are up-to-date). After the first successful build, subsequent builds skip the VICE compilation step entirely (outputs are up-to-date).
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) all ship inside the VICE 3.8 **C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) are copyrighted and are
tarball at `res/vice-3.8.tar.gz` and are bundled in the APK under `app/src/main/assets/`. **not** bundled in the APK. On first launch, `MainActivity` checks the app's
They are copied automatically to the external files dir on first launch — no user action needed. external files dir for these four files; if any are missing, it shows a blocking
dialog that lets the user import their own legally-obtained ROM dump via the
system file picker (see `docs/publish.md` §0). `res/extract_roms.sh` can pull
the ROMs out of `res/vice-3.8.tar.gz` into `res/roms/` for local sideloading
during development, but nothing in the Gradle build copies them into the APK.
### Key Kotlin/C files ### Key Kotlin/C files
@@ -114,7 +118,8 @@ They are copied automatically to the external files dir on first launch — no u
| `C64DisplayView.kt` | SurfaceView — blits 320×200 ARGB framebuffer, scaled with correct aspect ratio | | `C64DisplayView.kt` | SurfaceView — blits 320×200 ARGB framebuffer, scaled with correct aspect ratio |
| `C64KeyboardView.kt` | Multi-touch virtual C64 keyboard; fires `KeyEventListener` on press/release | | `C64KeyboardView.kt` | Multi-touch virtual C64 keyboard; fires `KeyEventListener` on press/release |
| `jni/vice_jni.c` | JNI wrapper — custom VICE video canvas writes frames to `g_framebuf[320×200]` | | `jni/vice_jni.c` | JNI wrapper — custom VICE video canvas writes frames to `g_framebuf[320×200]` |
| `jni/CMakeLists.txt` | NDK build; conditionally links `libvice.a` when `HAVE_VICE_SRC=1` | | `jni/nibconv_jni.c` | JNI wrapper around nibtools' nibconv — converts downloaded NIB/NBZ dumps to G64 |
| `jni/CMakeLists.txt` | NDK build; conditionally links `libvice.a`/`libnibtools.a` when their `HAVE_*` flags are set |
### Disk images ### Disk images
@@ -123,3 +128,34 @@ same external files directory as the ROMs (via USB or adb), then load via:
```kotlin ```kotlin
engine.loadDisk(getExternalFilesDir(null)!!.absolutePath + "/schwert_und_magie_1.d64") engine.loadDisk(getExternalFilesDir(null)!!.absolutePath + "/schwert_und_magie_1.d64")
``` ```
Alternatively, the in-app **Download Disks** button (next to Import Disks) fetches all
8 episode disks from the Internet Archive's C64 Preservation Project and converts them
to G64 automatically — see "Disk download (nibtools)" below.
### Disk download (nibtools)
The Internet Archive's C64 Preservation Project only has these disks as `.nbz`
(nibtools' compressed raw-GCR NIB format) — not `.d64`. `MainActivity.downloadDisks()`
fetches each of the 8 `.nbz` files (one per disk side) and runs them through nibtools'
`nibconv` to produce G64 images (VICE attaches G64 exactly like D64, detecting the
format from file content, not the extension). G64 was chosen over the lossy D64
reconstruction nibconv also supports, since these old dumps aren't always cleanly
sector-readable — G64 preserves whatever the original drive actually saw.
Only `nibconv`'s pure file-format conversion code is built (`gcr.c prot.c fileio.c
crc.c md5.c lz.c nibconv.c`) — nibtools' hardware-access tools (`nibread`/`nibwrite`,
which talk to a real 1541 over OpenCBM) are not needed and not built. `jni/nibtools_android/`
stubs out the OpenCBM header so the unused declarations that pull it in still compile.
**nibtools compilation is automatic**, the same way as VICE:
1. The Gradle `buildNibtools` task runs before every native CMake build
2. It calls `app/src/main/jni/build_nibtools.sh`, which unpacks `res/nibtools-<rev>.tar.gz`
and cross-compiles the files above into `nibtools-libs/<abi>/libnibtools.a`
3. `CMakeLists.txt` auto-detects the library via `EXISTS` and links it in
nibconv's `main()` is renamed to `nibtools_nibconv_main` at compile time (`-Dmain=...`)
so it can coexist in the same shared library as the JNI entry points. It's invoked
from `nibconv_jni.c` on a throwaway pthread — nibconv calls `exit()` on malformed input,
which is wrapped (`-Wl,--wrap=exit`) to `pthread_exit()` so a bad conversion only kills
that disposable thread, never the app process or the calling JNI thread.
+177
View File
@@ -0,0 +1,177 @@
<p align="center">
<img src="SchwertUndMagieOnPebbleCompanionApp/res/app-icon-round.png" width="290" alt="App icon">
</p>
# Schwert und Magie on Pebble
A port of the classic German C64 text-adventure RPG series **Schwert und Magie**
(German Design Group, 19891992) to the Pebble Time 2 smartwatch.
## Architecture
The phone runs VICE 3.8 as a companion app and streams the C64 text screen to
the watch. Button presses on the watch flow back to VICE via Bluetooth.
![System overview](docs/diagrams/system-overview.png)
## The game
*Schwert und Magie* is an 8-episode text-adventure / RPG series for the
Commodore 64, published in German by boeder-Verlag. Two episodes share one
disk:
| Disk | Episodes |
|------|----------|
| I | Folge 1: Das geheimnisvolle Kraut · Folge 2: Der unheimliche Tempel |
| II | Folge 3: Das Piratenhaus · Folge 4: Die Burg des Magiers |
| III | Folge 5: Das Haus des Vampirs · Folge 6: Der Turm des Todes |
| IV | Folge 7: Unter Wasser · Folge 8: Insel der Wunder |
The copy-protection scheme asks for a word from the printed *Anleitung*
(manual). The app includes the full manual text with a look-up button (📖) so
you can answer the prompt without keeping the paper around.
## Repository layout
```
SchwertUndMagieOnPebbleWatchApp/ Pebble watchapp (C + PebbleKit JS)
SchwertUndMagieOnPebbleCompanionApp/ Android companion app (Kotlin + NDK)
versions/ Original .d64 disk images (4 disks)
docs/ Architecture notes and diagrams
build-image/ Dockerfile for the release build environment
dist/ Build artifacts (gitignored) — see dist.sh
```
Run `./dist.sh` to build release artifacts for both apps in one step:
`dist/schwert-und-magie-<version>.{aab,apk,pbw}` — the signed Android AAB
(Play Store), APK (sideload), and Pebble `.pbw` (Rebble / direct install).
Requires the Android release keystore to already be configured
(`docs/publish.md` §2.1-2.3). The version comes from the current git tag by
default (`git tag v1.2.3`); see `docs/publish.md` §4 for pushing that tag to
trigger an automated, containerized build via Gitea Actions instead.
## How it works
Three processes cooperate across two devices:
```
Pebble watch
│ Bluetooth AppMessage
Core for Pebble (on phone) — PebbleKit JS bridge
│ HTTP on 127.0.0.1:8888
Android companion app — NanoHTTPD server
│ JNI
VICE 3.8 (C64 emulator, cross-compiled for ARM64/x86_64)
```
The watch displays the C64 text screen (40×25 cells) in a scroll view and
provides a key wheel for the number keys most used by the game. The companion
app renders VICE's 320×200 framebuffer on-screen and also provides a virtual
C64 keyboard for direct input.
See [`docs/architecture.md`](docs/architecture.md) for the full design.
## Requirements
- **Android phone** running Android 7.0+ (API 24)
- **Pebble Time 2** (or any Pebble running firmware 3.x — the `.pbw` targets
all SDK 3 platforms)
- **Core for Pebble** installed on the phone (the community Pebble app)
- **C64 ROM files** — `kernal`, `basic`, `chargen`, `1541` — legally obtained
from your own C64 or from Cloanto's C64 Forever. The app prompts you to
import them on first launch; they are never bundled.
- **Disk images** — `.d64` or `.g64` files for the four game disks. You can
import your own or use the in-app **Fetch Disks** button to download
community-preserved dumps from the Internet Archive's C64 Preservation
Project (requires nibtools, which is cross-compiled automatically).
## Building
### Android companion app
```bash
cd SchwertUndMagieOnPebbleCompanionApp
./gradlew assembleDebug # debug build
./gradlew assembleRelease # signed release build (requires keystore.properties)
./gradlew installDebug # build + install on connected device
```
VICE 3.8 and nibtools are cross-compiled automatically the first time — the
Gradle `buildVice` and `buildNibtools` tasks unpack the source tarballs from
`res/` and build `libvice.a` / `libnibtools.a` for ARM64 and x86_64. The NDK
must be installed (Android Studio → SDK Manager → SDK Tools → NDK (Side by
side)).
### Pebble watch app
```bash
cd SchwertUndMagieOnPebbleWatchApp
pebble build
pebble install --phone <phone-ip>
```
In headless / CI environments add `--vnc` to every emulator command.
## Getting disk images onto the device
**Option A — Fetch Disks button** (in-app)
Opens a dialog, downloads all 8 episode disks as `.nbz` files from the
Internet Archive C64 Preservation Project, and converts them to G64 using
nibtools. Requires a network connection; conversion runs on-device.
**Option B — manual copy**
Copy `.d64` or `.g64` files to the app's external files directory via USB or
`adb push`, matching the names expected by the drawer (e.g. `SCHWUM1A.D64`).
## Hero saves
Hero characters are stored on separate hero disks (up to 3 slots). The app
manages these from the drawer — create a new hero disk, load an episode disk,
then swap to the hero disk when the game asks for it. A built-in stat editor
lets you inspect and edit a saved hero's attributes directly.
## Save states
The 💾 button saves or restores a full VICE snapshot (CPU registers, RAM,
VICII, SID, CIA) to one of three slots. Snapshots go through a CPU trap to
ensure register consistency — see `docs/architecture.md §4.4`.
## Publishing
See [`docs/publish.md`](docs/publish.md) for keystore setup, Google Play,
F-Droid, and Rebble submission.
### Automated releases (Gitea Actions)
Pushing a tag `vX.Y.Z` builds both apps in a containerized runner and
publishes a Gitea Release with the versioned artifacts attached — see
`docs/publish.md` §4. One-time setup:
1. Build and push the build environment image (`./build-image.sh`, needs
`registry.env` — copy from `registry.env.example`).
2. Register a self-hosted `act_runner` with a Docker executor.
3. Add repo secrets under **Settings → Actions → Secrets**:
| Secret | Value |
|---|---|
| `RELEASE_KEYSTORE_B64` | `base64 -w0 SchwertUndMagieOnPebbleCompanionApp/release.keystore` |
| `RELEASE_KEYSTORE_PROPERTIES` | full contents of `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` |
`GITEA_TOKEN` is injected automatically per job — nothing to add for it.
4. `git tag v1.2.3 && git push origin v1.2.3`.
Full details, including runner registration commands, are in
`docs/publish.md` §4.
## License
The watch app and companion app source code in this repository are released
under the **MIT License**.
The *Schwert und Magie* game content (disk images, manual text) remains the
property of its original authors and is not part of this license. VICE and
nibtools are GPLv2 and are built from source at compile time; their source
tarballs in `res/` are not covered by this repository's MIT license.
@@ -13,3 +13,6 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
# ROM files extracted from the VICE tarball by res/extract_roms.sh
/res/roms
@@ -1,6 +1,7 @@
/build /build
# ROM files extracted from the VICE tarball at build time (see extractViceRoms task) # Copyrighted Commodore ROMs are never bundled — users import their own at
# runtime (MainActivity.showRomImportDialog). Ignored here as a safety net.
/src/main/assets/kernal /src/main/assets/kernal
/src/main/assets/basic /src/main/assets/basic
/src/main/assets/chargen /src/main/assets/chargen
@@ -4,6 +4,11 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
} }
val keystoreProps = Properties().apply {
rootProject.file("keystore.properties").takeIf { it.exists() }
?.reader()?.use { load(it) }
}
android { android {
namespace = "de.ladkau.schwertundmagieonpebblecompanionapp" namespace = "de.ladkau.schwertundmagieonpebblecompanionapp"
compileSdk { compileSdk {
@@ -35,8 +40,18 @@ android {
} }
} }
signingConfigs {
create("release") {
keystoreProps["storeFile"]?.let { storeFile = rootProject.file(it as String) }
storePassword = keystoreProps["storePassword"] as String?
keyAlias = keystoreProps["keyAlias"] as String?
keyPassword = keystoreProps["keyPassword"] as String?
}
}
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -122,55 +137,86 @@ tasks.register("buildVice") {
} }
} }
// Run buildVice before any CMake configure or build step. // ---------------------------------------------------------------------------
// nibtools cross-compilation task
//
// Builds nibconv (NIB/NBZ -> G64/D64 disk image conversion, used by the
// "Download Disks" feature) the same way buildVice builds VICE above.
// Skipped entirely if nibtools-libs/<abi>/libnibtools.a already exists.
// The tarball at <project>/res/nibtools-91344e0ee3.tar.gz is unpacked by
// build_nibtools.sh.
// ---------------------------------------------------------------------------
val nibtoolsLibArm = layout.projectDirectory.file("src/main/jni/nibtools-libs/arm64-v8a/libnibtools.a")
val nibtoolsLibX86 = layout.projectDirectory.file("src/main/jni/nibtools-libs/x86_64/libnibtools.a")
val nibtoolsTarball = layout.projectDirectory.file("../res/nibtools-91344e0ee3.tar.gz")
tasks.register("buildNibtools") {
group = "build"
description = "Cross-compile nibtools' nibconv for Android (ARM64 + x86_64)"
inputs.file(nibtoolsTarball)
outputs.file(nibtoolsLibArm)
outputs.file(nibtoolsLibX86)
doLast {
if (nibtoolsLibArm.asFile.exists() && nibtoolsLibX86.asFile.exists()) {
logger.lifecycle("nibtools already built — skipping")
return@doLast
}
val localProps = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }
?.reader()?.use { load(it) }
}
val sdkDir = localProps.getProperty("sdk.dir")
?: System.getenv("ANDROID_HOME")
?: ""
val ndkPath = localProps.getProperty("ndk.dir")
?: System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: System.getenv("NDK")
?: sdkDir.takeIf { it.isNotEmpty() }?.let { sdk ->
file("$sdk/ndk").takeIf { it.isDirectory }
?.listFiles()?.sorted()?.lastOrNull()?.absolutePath
?: "$sdk/ndk-bundle".takeIf { file("$sdk/ndk-bundle").isDirectory }
}
?: throw GradleException(
"Android NDK not found.\n" +
"Install via: Android Studio → SDK Manager → SDK Tools → NDK (Side by side)"
)
logger.lifecycle("Building nibtools with NDK at $ndkPath")
val proc = ProcessBuilder("bash", "build_nibtools.sh")
.directory(layout.projectDirectory.dir("src/main/jni").asFile)
.redirectErrorStream(true)
.also { it.environment()["NDK"] = ndkPath }
.start()
proc.inputStream.bufferedReader().forEachLine { logger.lifecycle(it) }
val exit = proc.waitFor()
if (exit != 0) throw GradleException("build_nibtools.sh failed (exit $exit)")
layout.projectDirectory.file("src/main/jni/CMakeLists.txt")
.asFile.setLastModified(System.currentTimeMillis())
logger.lifecycle("nibtools build complete — CMakeLists.txt touched for re-evaluation")
}
}
// Run buildVice and buildNibtools before any CMake configure or build step.
tasks.whenTaskAdded { tasks.whenTaskAdded {
if (name.startsWith("configureCMake") || name.startsWith("buildCMake") || if (name.startsWith("configureCMake") || name.startsWith("buildCMake") ||
name.startsWith("externalNativeBuild")) { name.startsWith("externalNativeBuild")) {
dependsOn("buildVice") dependsOn("buildVice")
dependsOn("buildNibtools")
} }
} }
// --------------------------------------------------------------------------- // Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are
// ROM extraction task // copyrighted Commodore firmware. The app prompts the user to import their
// // own ROM dump at runtime instead (see MainActivity.showRomImportDialog()).
// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android // res/extract_roms.sh remains available for extracting ROMs from the VICE
// assets directory so they are packaged into the APK automatically. // tarball locally (e.g. to sideload onto a test device), but nothing in this
// Output files are gitignored — the tarball is the single source of truth. // build copies them into assets/ or the APK.
// ---------------------------------------------------------------------------
val assetsDir = layout.projectDirectory.dir("src/main/assets")
tasks.register<Copy>("extractViceRoms") {
group = "build"
description = "Extract C64 and 1541 ROMs from the VICE tarball into assets/"
from(tarTree(viceTarball.asFile)) {
include("vice-3.8/data/C64/kernal-901227-03.bin")
include("vice-3.8/data/C64/basic-901226-01.bin")
include("vice-3.8/data/C64/chargen-901225-01.bin")
include("vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin")
eachFile {
// Flatten the tarball directory structure: place each ROM directly
// in assets/ with its short name rather than the versioned filename.
relativePath = RelativePath(true, when (name) {
"kernal-901227-03.bin" -> "kernal"
"basic-901226-01.bin" -> "basic"
"chargen-901225-01.bin" -> "chargen"
"dos1541-325302-01+901229-05.bin" -> "1541"
else -> name
})
}
includeEmptyDirs = false
}
into(assetsDir)
}
// Run extractViceRoms before assets are merged into the APK.
tasks.whenTaskAdded {
if (name.startsWith("merge") && name.endsWith("Assets")) {
dependsOn("extractViceRoms")
}
}
dependencies { dependencies {
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
@@ -0,0 +1,223 @@
SCHWERT UND MAGIE - Spielanleitung
====================================
(abgetippt aus der Original-Anleitung in res/schwert-und-magie-anleitung.pdf)
Diese Abschrift behält die Absatz- und Zeilenumbrüche des Originals bei.
Die Anleitung ist ein gefaltetes Blatt ohne Seitenzahlen - die drei Seiten
heißen Vorderseite, linke Innenseite und rechte Innenseite (Abschnitte
unten). Der Kopierschutz fragt ein Wort über diese Hierarchie ab:
Seite -> Absatz -> Zeile -> Wort
Seite = einer der drei Abschnitte (Vorderseite / linke Innenseite /
rechte Innenseite) weiter unten.
Absatz = durch Leerzeile getrennter Textblock innerhalb der Seite;
beginnt auf jeder Seite neu bei 1.
Zeile = gedruckte Zeile innerhalb des Absatzes, ab 1 gezählt.
Wort = durch Leerzeichen getrenntes Wort innerhalb der Zeile, ab 1
gezählt, wie im gedruckten Original.
----------------------------------------------------------------------
=== Vorderseite ===
Absatz 1:
Zeile 1: Diese Abenteuerspielreihe ist im Fantasybereich angesiedelt. Das heißt, in
Zeile 2: einer Welt, die etwa der unseres Mittelalters entspricht und in der oft das
Zeile 3: Schwert regiert. In dieser Welt gibt es aber auch noch Magie, Dämonen,
Zeile 4: Monster, Drachen und Zauberer.
Absatz 2:
Zeile 1: Es sind Abenteuerspiele, bei denen der Spieler einen Helden (oder eine
Zeile 2: Heldin) verkörpert, der eine bestimmte Aufgabe lösen muß. Anders als bei
Zeile 3: den gängigen Abenteuerspielen werden hier in jeder Situation Vorschläge
Zeile 4: gemacht, was Dein Held als nächstes tun kann, und Du mußt Dich für eine
Zeile 5: Alternative entscheiden.
Absatz 3:
Zeile 1: Das mag Dir vielleicht im ersten Moment unflexibler vorkommen, hat aber
Zeile 2: zwei Vorteile. Einmal konnte der Speicherplatz für einen Parser
Zeile 3: (Programmteil, der die Befehle des Spielers analysiert) auf ein Minimum
Zeile 4: beschränkt, und so das Spiel selbst etwas umfangreicher gestaltet werden.
Zeile 5: Zum anderen wird hier niemand scheitern, weil er nicht auf einen bestimmten
Zeile 6: Begriff oder eine bestimmte Art zu handeln kommt: alle Möglichkeiten sind
Zeile 7: sofort erkennbar.
Absatz 4:
Zeile 1: Es ist ein reines Text-Abenteuer; eine schöne Grafik verbraucht zuviel
Zeile 2: Speicherplatz, und grob gemachte Bilder oder ständiges Nachladen wollten
Zeile 3: Dir ersparen. Wir haben dafür die Beschreibungstexte sehr ausführlich
Zeile 4: gestaltet, was oft eine bessere Atmosphäre schafft, als es durch ein Bild
Zeile 5: erreicht werden kann.
Absatz 5:
Zeile 1: Es ist aber gleichzeitig auch ein Rollenspiel, denn Deine Spielfigur
Zeile 2: bekommt zu Anfang bestimmte Eigenschaften zugewiesen, die sie im Verlauf
Zeile 3: des Spieles oftmals unter Beweis stellen muß. Diese Eigenschaften sind im
Zeile 4: Einzelnen:
Absatz 6:
Zeile 1: Die Vitalität (Vit) gibt die körperliche Verfassung wieder. Wird Dein Held
Zeile 2: verwundet, so sinkt sie. Fällt sie auf Null, ist Dein Held tot. Auch Deine
Zeile 3: Gegner haben eine bestimmte Vitalität.
Absatz 7:
Zeile 1: Die Tapferkeit (Ta) entspricht dem überlegten Mut ebenso wie der
Zeile 2: Tollkühnheit Deines Helden. Tapferkeit entscheidet über die Initiative im
Zeile 3: Kampf, ob man dem Anblick eines Monsters standhalten kann, sich traut,
Zeile 4: unheimliche Orte zu betreten oder gefährliche Dinge zu tun etc.
Absatz 8:
Zeile 1: Die Intelligenz (In) ist die Fähigkeit, Situationen zu erfassen und
Zeile 2: logische Schlüsse daraus zu ziehen. Wer intelligent ist, kann Rätsel oder
Zeile 3: Zusammenhänge besser verstehen und hat schon eher mal eine gute Idee.
Absatz 9:
Zeile 1: Der Charme (Ch) ist die Fähigkeit, andere zu beeinflussen und für sich zu
Zeile 2: gewinnen. Das ist von vielen Faktoren abhängig, z.B. Aussehen, Klang der
Zeile 3: Stimme, Auftreten etc. Charme ist nützlich zum Gewinnen von Freunden,
Zeile 4: beim Handeln oder verhandeln, bei Bitten etc.
=== linke Innenseite ===
Absatz 1:
Zeile 1: Die Stärke (St) braucht wohl nicht weiter erläutert zu werden. Wer hier
Zeile 2: einen Wert über 60 Prozent erreicht, kann im Kampf so kräftig zudreschen,
Zeile 3: daß er für jede zusätzliche 10 Prozent ab 65 % zusätzliche Trefferwirkung
Zeile 4: erzielt.
Absatz 2:
Zeile 1: Die Geschicklichkeit (Ge) ist notwendig zum Ausweichen von Fallen, Öffnen
Zeile 2: von Schlössern etc. Wer hier einen Wert über 60 % besitzt, dessen
Zeile 3: Kampfwerte werden um 5 Prozent gesteigert.
Absatz 3:
Zeile 1: Der Angriff (An) ist die Fähigkeit, im Kampf einen Schlag gegen den Gegner
Zeile 2: zu führen; also die aggressive Fertigkeit seine Waffe zu führen.
Absatz 4:
Zeile 1: Die Verteidigung (Ve) ist die Fähigkeit, im Kampf den Angriff eines
Zeile 2: Gegners, der den Helden treffen würde, abzuwehren; also die defensive
Zeile 3: Kampffertigkeit.
Absatz 5:
Zeile 1: Die Eigenschaftswerte werden in Prozenten ausgedrückt, wobei der Wert 100 %
Zeile 2: natürlich perfekt ist. Da aber niemand vollkommen ist, kann kein Wert 90 %
Zeile 3: übersteigen. 50 % sind guter Durchschnitt. Wann immer eine heikle Situation
Zeile 4: entsteht, bei der eine dieser Eigenschaften eine besondere Rolle spielt,
Zeile 5: wird Dein Held einer Prüfung unterzogen. Dabei wird ein Wert bestimmt, der
Zeile 6: von dem Eigenschaftswert des Helden und der Schwierigkeit der abzulegenden
Zeile 7: Prüfung abhängt. Sodann wird durch Zufall ermittelt, wie gut sich Dein Held
Zeile 8: dabei anstellt. Erreicht er den erforderlichen Wert, ist die Prüfung
Zeile 9: gelungen, und das, was Dein Held vorhatte, klappt. Das Mißlingen einer
Zeile 10: solchen Prüfung hat allerdings meist ziemlich unangenehme Konsequenzen.
Absatz 6:
Zeile 1: Die folgenden Werte sind keine echten Eigenschaften, da sie von der
Zeile 2: Ausrüstung abhängig sind:
Absatz 7:
Zeile 1: Die Trefferwirkung (Tw), bzw. die Waffenklasse (Wk) ist ein Maßstab für den
Zeile 2: Schaden, den ein durchschnittlich kräftiger Krieger mit der Waffe, die er
Zeile 3: gerade führt, maximal anrichten kann. Nimmt man eine andere Waffe, kann
Zeile 4: sich auch dieser Wert ändern.
Absatz 8:
Zeile 1: Der Schutzfaktor (Sf) ist ein Index dafür, wie gut man vor gegnerischen
Zeile 2: Hieben geschützt ist. Je besser die Rüstung, desto besser ist auch der
Zeile 3: Schutz. Er gibt die Höhe der Trefferwirkung an, die die Rüstung
Zeile 4: kompensiert. Ein Schild kann den Faktor erhöhen.
Absatz 9:
Zeile 1: Die obige Beschreibung läßt Dich schon vermuten, daß es hier nicht
Zeile 2: einfach der Befehl "Töte Monster" gegeben, sondern in der Tat ein richtiger
Zeile 3: Kampf simuliert wird, dessen Ausgang höchst ungewiß ist. Natürlich sind
Zeile 4: auch wir der Auffassung, daß es immer besser ist, Gewalt zu vermeiden und
Zeile 5: Probleme besser mit dem Kopf als mit dem Schwert gelöst werden sollten,
Zeile 6: aber manchmal geht es einfach nicht anders. Für diese Fälle wollen wir das
Zeile 7: Gefecht so realistisch wie möglich ablaufen lassen.
Absatz 10:
Zeile 1: Kommt es zum Kampf, steht Dein Held dem Gegner gegenüber, der auch gewisse
Zeile 2: Eigenschaften haben wird, woraus Du ersehen kannst, wie gut er kämpft und
Zeile 3: wie leicht oder schwierig er zu besiegen ist. Es können auch mehrere Gegner
Zeile 4: auf einmal sein, die alle gleichzeitig angreifen. Die folgende Beschreibung
Zeile 5: des Kampfablaufs gilt für beide Seiten gleichermaßen:
Absatz 11:
Zeile 1: Wer einen Gegner angreift, muß eine Prüfung auf seinen Angriffswert
Zeile 2: ablegen. Mißlingt diese, geht der Schlag fehl. Gelingt sie jedoch, ist der
Zeile 3: Hieb so gut geführt, daß er den Gegner treffen würde - würde, weil dieser
Zeile 4: nun versuchen wird, den Schlag abzuwehren. Dies kann er, wenn eine Prüfung
Zeile 5: auf seine Verteidigung gelingt. Mißlingt diese Prüfung, kann er den Hieb
Zeile 6: nicht abwenden und wird getroffen.
=== rechte Innenseite ===
Absatz 1:
Zeile 1: Wenn man seinen Gegner trifft, wird die Wirkung ermittelt, die der Schlag
Zeile 2: erzielt. Sie liegt zufällig zwischen der Hälfte und dem Maximum der Waffen-
Zeile 3: klasse, da nicht jeder Hieb gleich kräftig geführt wird und gleich gut
Zeile 4: trifft. Bei Deinem Helden kommt eventuell, noch ein Bonus für große Stärke
Zeile 5: hinzu.
Absatz 2:
Zeile 1: Nun wird von der Trefferwirkung noch der Schutzfaktor des Getroffenen
Zeile 2: abgezogen. Das Ergebnis ist der Schaden, der von dessen Vitalität
Zeile 3: subtrahiert wird.
Absatz 3:
Zeile 1: Beispiel: Ein Schwerthieb (Tw max. 10) trifft mit der Wucht von 9. Der
Zeile 2: Getroffene trägt eine Lederrüstung (Sf 3) und einen Holzschild (Sf 1), dann
Zeile 3: werden 4 Punkte von der Wirkung abgezogen. Übrig bleiben 5 Punkte Schaden,
Zeile 4: die von dem Vit abgezogen werden.
Absatz 4:
Zeile 1: Für Deinen Helden gibt es zwei Sonderregeln bei seinen Angriffen:
Absatz 5:
Zeile 1: Mit einer Wahrscheinlichkeit von 10 % gelingt ihm ein "guter Treffer". Das
Zeile 2: ist ein Hieb, der so gut geführt ist, daß der Gegner keine Gelegenheit mehr
Zeile 3: zur Abwehr hat.
Absatz 6:
Zeile 1: Mit einer 5%igen Wahrscheinlichkeit gelingt ihm ein sogenannter
Zeile 2: "Glückstreffer". Dies ist ein "guter Treffer", der durch Zufall eine
Zeile 3: ungeschützte oder empfindliche Stelle des Gegners trifft und nicht von der
Zeile 4: Rüstung abgeschwächt wird.
Absatz 7:
Zeile 1: Deine Gegner können dies zum Glück nicht. Dafür haben sie einen Vorteil,
Zeile 2: wenn sie zu mehreren sind. In diesem Fall kann in einem Kampfzug für jeden
Zeile 3: Gegner ein Angriff gegen Dich geführt werden, während Du nur einmal
Zeile 4: angreifen kannst.
Absatz 8:
Zeile 1: Da es sich um eine Abenteuerreihe handelt, die auch weiter fortgesetzt
Zeile 2: werden soll, wird ein erstellter Held auf Diskette gespeichert, und kann so
Zeile 3: in mehreren Abenteuern seine "Karriere" aufbauen. Nach jedem bestandenen
Zeile 4: Abenteuer werden nämlich seine Eigenschaften verbessert, was ihm das
Zeile 5: nächste Abenteuer etwas leichter macht.
Absatz 9:
Zeile 1: Stirbt ein Spieler jedoch auf irgendeine Weise im Spiel, so ist er wirklich
Zeile 2: "tot", und seine Laufbahn ist für immer zu Ende, denn er ist auch auf
Zeile 3: geheimnisvolle Weise von der Speicherdiskette ins Jenseits verschwunden.
Zeile 4: Dann bleibt Dir nichts weiter übrig, als einen neuen Helden zu erstellen,
Zeile 5: der dann natürlich noch entsprechend schwach ist. Daher solltest Du Helden,
Zeile 6: die schon weit gekommen sind, mit entsprechender Umsicht führen, wenn Du
Zeile 7: sie nicht verlieren möchtest.
Absatz 10:
Zeile 1: Für jedes bestandene Abenteuer steigt Dein Held einen Grad höher. Je nach
Zeile 2: der Schwierigkeit des Abenteuers erhöhen sich seine Eigenschaften und seine
Zeile 3: Vitalität. Dies erleichtert das folgende Abenteuer bei den Prüfungen, aber
Zeile 4: nicht immer im Kampf, wo sich die Gegner manchmal der Stufe des Spielers
Zeile 5: angleichen.
Absatz 11:
Zeile 1: Hebe Deine gespeicherten Helden gut auf, denn es werden bald weitere
Zeile 2: Abenteuer dieser Reihe erscheinen...
Absatz 12:
Zeile 1: Wir wünschen Dir spannende Unterhaltung!
@@ -28,6 +28,12 @@ class C64Engine {
/** Eject the disk and hard-reset the C64 to a BASIC ready prompt. */ /** Eject the disk and hard-reset the C64 to a BASIC ready prompt. */
external fun resetMachine(): Boolean external fun resetMachine(): Boolean
/** Save a full machine snapshot to [path] (queued to the VICE thread). */
external fun saveState(path: String): Boolean
/** Restore a machine snapshot from [path] (queued to the VICE thread). */
external fun loadState(path: String): Boolean
/** Run the emulator for one video frame (~20 000 CPU cycles). */ /** Run the emulator for one video frame (~20 000 CPU cycles). */
external fun runFrame() external fun runFrame()
@@ -52,6 +58,16 @@ class C64Engine {
/** Monotonically increasing frame counter — incremented once per rendered C64 frame. */ /** Monotonically increasing frame counter — incremented once per rendered C64 frame. */
external fun getFrameCount(): Int external fun getFrameCount(): Int
/** Current 40x25 C64 text screen as ASCII, rows separated by '\n'. */
external fun getScreenText(): String
/**
* Converts a disk image file at [inPath] to the format implied by [outPath]'s
* extension (e.g. .nbz/.nib -> .g64/.d64) via nibtools' nibconv, run in-process.
* Returns false (without writing [outPath]) if nibtools wasn't compiled in.
*/
external fun convertDiskImage(inPath: String, outPath: String): Boolean
companion object { companion object {
init { System.loadLibrary("vice_jni") } init { System.loadLibrary("vice_jni") }
@@ -12,9 +12,12 @@ import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.view.Choreographer import android.view.Choreographer
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@@ -26,10 +29,17 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.GZIPInputStream
private const val PORT = 8888 private const val PORT = 8888
private const val TAG = "SuM" private const val TAG = "SuM"
@@ -64,6 +74,24 @@ class MainActivity : AppCompatActivity() {
// ---- disk slots --------------------------------------------------------- // ---- disk slots ---------------------------------------------------------
// ---- D64 helpers (shared by createBlankD64 and the hero editor) ----------
private val d64Spt = intArrayOf(
0,
21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21,
19, 19, 19, 19, 19, 19, 19,
18, 18, 18, 18, 18, 18,
17, 17, 17, 17, 17
)
private fun d64Off(track: Int, sector: Int): Int {
var off = 0
for (t in 1 until track) off += d64Spt[t] * 256
return off + sector * 256
}
// ---- disk slots ---------------------------------------------------------
private val diskSlots = listOf( private val diskSlots = listOf(
DiskSlot(1, 'A', R.id.btn_ep_1a, "Folge 1: Das geheimnisvolle Kraut"), DiskSlot(1, 'A', R.id.btn_ep_1a, "Folge 1: Das geheimnisvolle Kraut"),
DiskSlot(1, 'B', R.id.btn_ep_1b, "Folge 2: Der unheimliche Tempel"), DiskSlot(1, 'B', R.id.btn_ep_1b, "Folge 2: Der unheimliche Tempel"),
@@ -77,8 +105,9 @@ class MainActivity : AppCompatActivity() {
DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"), DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"),
DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"), DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"),
) )
private val episodeButtons = mutableMapOf<DiskSlot, Button>() private val episodeButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>() private val heroEditButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>()
private var activeSlot: DiskSlot? = null private var activeSlot: DiskSlot? = null
private var currentDiskPath: String? = null // path last passed to engine.loadDisk() private var currentDiskPath: String? = null // path last passed to engine.loadDisk()
@@ -104,6 +133,120 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---- Disk download (Internet Archive C64 Preservation Project) ----------
// Each entry is one side of one physical disk, dumped as a raw-GCR NIB by
// the preservation project (these old disks aren't always cleanly
// sector-readable, so the source format preserves whatever the original
// drive actually saw rather than reconstructing a — possibly wrong — D64).
// nibtools' nibconv (see jni/build_nibtools.sh) converts each to G64,
// which VICE attaches exactly like a D64 (engine.loadDisk/attachDisk
// detect the format from file content, not the extension).
private data class DiskDownload(val baseName: String, val url: String)
private val diskDownloads = listOf(
DiskDownload("SCHWUM1A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s1%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM1B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s2%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM2A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s1%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM2B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s2%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM3A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s1%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM3B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s2%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM4A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s1%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM4B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s2%5Bgdg_1991%5D%28german%29.nbz"),
)
private fun showDownloadDisksDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.btn_download_disks)
.setMessage(R.string.disks_download_msg)
.setPositiveButton(R.string.disks_download_confirm) { _, _ -> downloadDisks() }
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun downloadDisks() {
val assetDir = getExternalFilesDir(null) ?: filesDir
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val progressBar = android.widget.ProgressBar(
this, null, android.R.attr.progressBarStyleHorizontal
)
val statusTv = TextView(this).apply {
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
setPadding(0, dp(8), 0, 0)
}
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
addView(progressBar)
addView(statusTv)
}
val progressDialog = AlertDialog.Builder(this)
.setTitle(R.string.btn_download_disks)
.setView(content)
.setCancelable(false)
.show()
Thread {
var successCount = 0
for ((index, disk) in diskDownloads.withIndex()) {
mainHandler.post {
progressBar.isIndeterminate = false
progressBar.progress = 0
statusTv.text = getString(R.string.disks_downloading, index + 1, diskDownloads.size)
}
val tmpFile = File(cacheDir, "${disk.baseName}.nbz")
try {
val conn = (URL(disk.url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 30000
instanceFollowRedirects = true
}
try {
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${conn.responseCode}")
}
val total = conn.contentLengthLong
var lastPercent = -1
val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
if (total > 0) {
val percent = ((downloaded * 100) / total).toInt()
if (percent != lastPercent) {
lastPercent = percent
mainHandler.post { progressBar.progress = percent }
}
}
}
tmpFile.outputStream().use { out -> countingStream.copyTo(out) }
} finally {
conn.disconnect()
}
val finalFile = File(assetDir, "${disk.baseName}.G64")
if (engine.convertDiskImage(tmpFile.absolutePath, finalFile.absolutePath)) {
successCount++
mainHandler.post { appendLog("Downloaded: ${disk.baseName}") }
} else {
mainHandler.post { appendLog("Conversion failed: ${disk.baseName}") }
}
} catch (e: Exception) {
Log.e(TAG, "Disk download failed: ${disk.baseName}", e)
mainHandler.post { appendLog("Download failed: ${disk.baseName} (${e.message})") }
} finally {
tmpFile.delete()
}
}
mainHandler.post {
progressDialog.dismiss()
refreshDiskButtons()
val msg = getString(R.string.disks_download_done, successCount, diskDownloads.size)
appendLog(msg)
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
}
}.start()
}
// ---- lifecycle ---------------------------------------------------------- // ---- lifecycle ----------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -148,10 +291,15 @@ class MainActivity : AppCompatActivity() {
} }
findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() } findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() }
findViewById<Button>(R.id.btn_anleitung).setOnClickListener { showAnleitung() }
findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() }
findViewById<Button>(R.id.btn_import_disks).setOnClickListener { findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true) launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true)
} }
findViewById<Button>(R.id.btn_download_disks).setOnClickListener {
showDownloadDisksDialog()
}
val btnSound = findViewById<Button>(R.id.btn_sound) val btnSound = findViewById<Button>(R.id.btn_sound)
btnSound.setOnClickListener { btnSound.setOnClickListener {
@@ -187,6 +335,17 @@ class MainActivity : AppCompatActivity() {
findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) } findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) }
} }
val editButtonIds = mapOf(
R.id.btn_hero_1_edit to diskSlots.first { it.fileName == "HELD1.D64" },
R.id.btn_hero_2_edit to diskSlots.first { it.fileName == "HELD2.D64" },
R.id.btn_hero_3_edit to diskSlots.first { it.fileName == "HELD3.D64" },
)
for ((btnId, slot) in editButtonIds) {
val btn = findViewById<Button>(btnId)
heroEditButtons[slot] = btn
btn.setOnClickListener { showHeroEditor(slot) }
}
choreographer = Choreographer.getInstance() choreographer = Choreographer.getInstance()
initEmulator() initEmulator()
refreshDiskButtons() refreshDiskButtons()
@@ -200,7 +359,14 @@ class MainActivity : AppCompatActivity() {
private fun initEmulator() { private fun initEmulator() {
val assetDir = getExternalFilesDir(null) ?: filesDir val assetDir = getExternalFilesDir(null) ?: filesDir
assetDir.mkdirs() assetDir.mkdirs()
copyBundledRoms(assetDir) if (missingRoms(assetDir).isNotEmpty()) {
showRomImportDialog(assetDir)
return
}
startEmulator(assetDir)
}
private fun startEmulator(assetDir: File) {
val romDir = assetDir.absolutePath val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir) val ok = engine.initEmulator(romDir)
Log.d(TAG, "initEmulator=$ok romDir=$romDir") Log.d(TAG, "initEmulator=$ok romDir=$romDir")
@@ -227,22 +393,213 @@ class MainActivity : AppCompatActivity() {
}) })
} }
// ---- bundled ROM extraction --------------------------------------------- // ---- ROM import -----------------------------------------------------
private val bundledRoms = listOf("kernal", "basic", "chargen", "1541") // The companion app does not ship Commodore ROMs (they're copyrighted) —
// the user must supply their own dump on first run. See docs/publish.md §0.
private val requiredRoms = listOf("kernal", "basic", "chargen", "1541")
private fun copyBundledRoms(destDir: File) { private fun missingRoms(dir: File): List<String> =
for (name in bundledRoms) { requiredRoms.filter { !File(dir, it).exists() }
val dest = File(destDir, name)
if (dest.exists()) continue // Identifies which canonical ROM name a picked file corresponds to, based
try { // on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as
assets.open(name).use { input -> // well as plain names a user might have renamed a dump to.
dest.outputStream().use { output -> input.copyTo(output) } private fun romNameForFile(fileName: String): String? {
val lower = fileName.lowercase()
return when {
"kernal" in lower -> "kernal"
"chargen" in lower -> "chargen"
"basic" in lower -> "basic"
"1541" in lower -> "1541"
else -> null
}
}
private val romPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val assetDir = getExternalFilesDir(null) ?: filesDir
if (result.resultCode == Activity.RESULT_OK) {
val uris = mutableListOf<Uri>()
result.data?.clipData?.let { clip ->
for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri)
} ?: result.data?.data?.let { uris.add(it) }
for (uri in uris) {
val name = displayNameForUri(uri) ?: continue
val romName = romNameForFile(name) ?: continue
contentResolver.openInputStream(uri)?.use { input ->
File(assetDir, romName).outputStream().use { output -> input.copyTo(output) }
}
appendLog("Imported ROM: $romName")
} }
Log.d(TAG, "Copied bundled ROM: $name")
} catch (e: Exception) {
Log.e(TAG, "Failed to copy bundled ROM $name", e)
} }
val stillMissing = missingRoms(assetDir)
if (stillMissing.isEmpty()) {
startEmulator(assetDir)
} else {
Toast.makeText(
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
Toast.LENGTH_LONG
).show()
showRomImportDialog(assetDir)
}
}
private fun showRomImportDialog(dir: File) {
AlertDialog.Builder(this)
.setTitle(R.string.roms_missing_title)
.setMessage(getString(R.string.roms_missing_msg, missingRoms(dir).joinToString()))
.setCancelable(false)
.setPositiveButton(R.string.roms_import_button) { _, _ ->
launchPicker(romPickerLauncher, getString(R.string.rom_picker_title), multiSelect = true)
}
.setNeutralButton(R.string.roms_download_button) { _, _ -> downloadRoms(dir) }
.show()
}
// The same VICE 3.8 source tarball already bundled at res/vice-3.8.tar.gz
// (used to cross-compile VICE itself), fetched from VICE's own official
// GitHub release rather than shipped in the APK. The four ROMs are
// extracted from it client-side — see docs/publish.md §0 for why this is
// not equivalent to bundling them in the app.
private val viceTarballUrl =
"https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz"
private fun downloadRoms(destDir: File) {
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val progressBar = android.widget.ProgressBar(
this, null, android.R.attr.progressBarStyleHorizontal
).apply { isIndeterminate = true }
val statusTv = TextView(this).apply {
text = getString(R.string.roms_downloading)
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
setPadding(0, dp(8), 0, 0)
}
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
addView(progressBar)
addView(statusTv)
}
val progressDialog = AlertDialog.Builder(this)
.setTitle(R.string.roms_download_button)
.setView(content)
.setCancelable(false)
.show()
Thread {
try {
val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 30000
instanceFollowRedirects = true
}
try {
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${conn.responseCode}")
}
val total = conn.contentLengthLong
var lastPercent = -1
val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
if (total > 0) {
val percent = ((downloaded * 100) / total).toInt()
if (percent != lastPercent) {
lastPercent = percent
mainHandler.post {
progressBar.isIndeterminate = false
progressBar.progress = percent
statusTv.text = getString(R.string.roms_downloading_progress, percent)
}
}
}
}
extractRomsFromTarGz(countingStream, destDir)
} finally {
conn.disconnect()
}
mainHandler.post {
progressDialog.dismiss()
val stillMissing = missingRoms(destDir)
if (stillMissing.isEmpty()) {
appendLog("ROM download complete")
startEmulator(destDir)
} else {
appendLog("ROM download incomplete: ${stillMissing.joinToString()}")
Toast.makeText(
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
Toast.LENGTH_LONG
).show()
showRomImportDialog(destDir)
}
}
} catch (e: Exception) {
Log.e(TAG, "ROM download failed", e)
mainHandler.post {
progressDialog.dismiss()
appendLog("ROM download failed: ${e.message}")
Toast.makeText(this, R.string.roms_download_failed, Toast.LENGTH_LONG).show()
showRomImportDialog(destDir)
}
}
}.start()
}
// Minimal USTAR reader — pulls just the four named ROM entries out of the
// gzipped VICE source tarball without needing a tar library dependency.
private fun extractRomsFromTarGz(rawInput: InputStream, destDir: File) {
val wantedEntries = mapOf(
"vice-3.8/data/C64/kernal-901227-03.bin" to "kernal",
"vice-3.8/data/C64/basic-901226-01.bin" to "basic",
"vice-3.8/data/C64/chargen-901225-01.bin" to "chargen",
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" to "1541",
)
GZIPInputStream(rawInput).use { gz ->
val header = ByteArray(512)
while (true) {
var read = 0
while (read < 512) {
val n = gz.read(header, read, 512 - read)
if (n < 0) return
read += n
}
val name = String(header, 0, 100, Charsets.US_ASCII).trimEnd('\u0000', ' ')
val sizeField = String(header, 124, 12, Charsets.US_ASCII).trim('\u0000', ' ')
val size = if (sizeField.isEmpty()) 0L else sizeField.toLong(8)
val destName = wantedEntries[name]
if (destName != null) {
File(destDir, destName).outputStream().use { out -> copyExactly(gz, out, size) }
} else {
skipExactly(gz, size)
}
val remainder = size % 512
if (remainder != 0L) skipExactly(gz, 512 - remainder)
}
}
}
private fun copyExactly(input: InputStream, output: OutputStream, size: Long) {
val buf = ByteArray(8192)
var remaining = size
while (remaining > 0) {
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
if (n < 0) break
output.write(buf, 0, n)
remaining -= n
}
}
private fun skipExactly(input: InputStream, size: Long) {
val buf = ByteArray(8192)
var remaining = size
while (remaining > 0) {
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
if (n < 0) break
remaining -= n
} }
} }
@@ -258,6 +615,33 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---- watch key wheel ------------------------------------------------
// Labels sent by the watch's SELECT key wheel (see k_wheel_items in
// SchwertUndMagieOnPebbleFrontend.c) — movement in this game is mostly
// done with number keys, plus RETURN/SPACE to confirm.
private val watchKeyMap: Map<String, List<Int>> = mapOf(
"1" to listOf(C64Engine.KEY_1), "2" to listOf(C64Engine.KEY_2),
"3" to listOf(C64Engine.KEY_3), "4" to listOf(C64Engine.KEY_4),
"5" to listOf(C64Engine.KEY_5), "6" to listOf(C64Engine.KEY_6),
"7" to listOf(C64Engine.KEY_7), "8" to listOf(C64Engine.KEY_8),
"9" to listOf(C64Engine.KEY_9), "0" to listOf(C64Engine.KEY_0),
"RETURN" to listOf(C64Engine.KEY_RETURN),
"SPACE" to listOf(C64Engine.KEY_SPACE),
)
// Pulses a key press+release in response to a watch wheel selection.
// The companion HTTP server calls this off the main thread, so the
// release is posted via mainHandler like the rest of this class's
// cross-thread UI/engine touchpoints.
private fun injectWatchKey(label: String) {
val codes = watchKeyMap[label] ?: return
for (code in codes) engine.injectKey(code, true)
mainHandler.postDelayed({
for (code in codes) engine.injectKey(code, false)
}, 80)
}
// ---- HTTP server (watch bridge) ----------------------------------------- // ---- HTTP server (watch bridge) -----------------------------------------
private fun startHttpServer() { private fun startHttpServer() {
@@ -266,7 +650,9 @@ class MainActivity : AppCompatActivity() {
onKey = { cmd -> onKey = { cmd ->
Log.d(TAG, "Watch key: $cmd") Log.d(TAG, "Watch key: $cmd")
mainHandler.post { appendLog("WATCH: $cmd") } mainHandler.post { appendLog("WATCH: $cmd") }
} injectWatchKey(cmd)
},
getScreenText = { engine.getScreenText() }
) )
try { try {
server?.start() server?.start()
@@ -280,17 +666,25 @@ class MainActivity : AppCompatActivity() {
private fun diskFile(slot: DiskSlot): File? { private fun diskFile(slot: DiskSlot): File? {
val assetDir = getExternalFilesDir(null) ?: filesDir val assetDir = getExternalFilesDir(null) ?: filesDir
return assetDir.listFiles()?.firstOrNull { it.name.uppercase() == slot.fileName } // Episode disks may exist either as an imported/created D64 or as a
// G64 produced by downloadDisks() (nibconv output) — same base name.
val g64Name = slot.fileName.removeSuffix(".D64") + ".G64"
return assetDir.listFiles()?.firstOrNull {
val name = it.name.uppercase()
name == slot.fileName || name == g64Name
}
} }
private fun refreshDiskButtons() { private fun refreshDiskButtons() {
for (slot in diskSlots) { for (slot in diskSlots) {
val exists = diskFile(slot) != null
val btn = episodeButtons[slot] ?: continue val btn = episodeButtons[slot] ?: continue
btn.isEnabled = diskFile(slot) != null btn.isEnabled = exists
btn.backgroundTintList = if (slot == activeSlot) btn.backgroundTintList = if (slot == activeSlot)
ColorStateList.valueOf(Color.parseColor("#1A4A1A")) ColorStateList.valueOf(Color.parseColor("#1A4A1A"))
else else
ColorStateList.valueOf(Color.TRANSPARENT) ColorStateList.valueOf(Color.TRANSPARENT)
heroEditButtons[slot]?.isEnabled = exists
} }
} }
@@ -386,26 +780,10 @@ class MainActivity : AppCompatActivity() {
/** Returns a 174 848-byte blank formatted 1541 D64 image with [diskLabel] as the disk name. */ /** Returns a 174 848-byte blank formatted 1541 D64 image with [diskLabel] as the disk name. */
private fun createBlankD64(diskLabel: String): ByteArray { private fun createBlankD64(diskLabel: String): ByteArray {
// Sectors per track for a standard 35-track 1541 disk
val sectorsPerTrack = intArrayOf(
0, // index 0 unused
21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, // 1-17
19, // 18 (directory)
19, 19, 19, 19, 19, 19, // 19-24
18, 18, 18, 18, 18, 18, // 25-30
17, 17, 17, 17, 17 // 31-35
)
val data = ByteArray(174848) val data = ByteArray(174848)
fun offset(track: Int, sector: Int): Int {
var off = 0
for (t in 1 until track) off += sectorsPerTrack[t] * 256
return off + sector * 256
}
// BAM sector — track 18, sector 0 // BAM sector — track 18, sector 0
val bam = offset(18, 0) val bam = d64Off(18, 0)
data[bam + 0] = 18 // first directory track data[bam + 0] = 18 // first directory track
data[bam + 1] = 1 // first directory sector data[bam + 1] = 1 // first directory sector
data[bam + 2] = 0x41 // DOS version 'A' data[bam + 2] = 0x41 // DOS version 'A'
@@ -413,7 +791,7 @@ class MainActivity : AppCompatActivity() {
for (t in 1..35) { for (t in 1..35) {
val entry = bam + 4 + (t - 1) * 4 val entry = bam + 4 + (t - 1) * 4
val sectors = sectorsPerTrack[t] val sectors = d64Spt[t]
// All sectors free; track 18 has sectors 0 (BAM) and 1 (dir) pre-allocated // All sectors free; track 18 has sectors 0 (BAM) and 1 (dir) pre-allocated
var mask = (1 shl sectors) - 1 var mask = (1 shl sectors) - 1
var free = sectors var free = sectors
@@ -440,13 +818,194 @@ class MainActivity : AppCompatActivity() {
for (i in 167..255) data[bam + i] = 0xA0.toByte() for (i in 167..255) data[bam + i] = 0xA0.toByte()
// First directory sector — track 18, sector 1 // First directory sector — track 18, sector 1
val dir = offset(18, 1) val dir = d64Off(18, 1)
data[dir + 0] = 0x00 // no next track (end of chain) data[dir + 0] = 0x00 // no next track (end of chain)
data[dir + 1] = 0xFF.toByte() data[dir + 1] = 0xFF.toByte()
return data return data
} }
// ---- hero save editor ---------------------------------------------------
private fun petsciiToString(bytes: ByteArray): String = buildString {
for (b in bytes) {
val v = b.toInt() and 0xFF
when {
v == 0x00 || v == 0xA0 -> return@buildString
v in 0xC1..0xDA -> append((v - 0x80).toChar()) // shifted → A-Z uppercase
v in 0x41..0x5A -> append((v + 0x20).toChar()) // unshifted → a-z lowercase
v in 0x30..0x39 -> append(v.toChar())
v == 0x20 -> append(' ')
}
}
}
/** Walks the directory chain and returns every PRG file found on the disk. */
private fun findAllHeroPrgs(d64: ByteArray): List<HeroPrg> {
val result = mutableListOf<HeroPrg>()
var track = 18; var sector = 1
while (track != 0) {
val base = d64Off(track, sector)
for (i in 0 until 8) {
val e = base + 2 + i * 32
val ft = d64[e].toInt() and 0xFF
if ((ft and 0x80) != 0 && (ft and 0x0F) == 0x02) {
val t = d64[e + 1].toInt() and 0xFF
val s = d64[e + 2].toInt() and 0xFF
if (t != 0) {
val sectorOff = d64Off(t, s)
val prgBase = sectorOff + 2
val name = petsciiToString(d64.copyOfRange(prgBase + 0x02, prgBase + 0x12))
result.add(HeroPrg(
name = name,
sectorOff = sectorOff,
values = intArrayOf(
d64[prgBase + 0x12].toInt() and 0xFF,
d64[prgBase + 0x13].toInt() and 0xFF,
d64[prgBase + 0x14].toInt() and 0xFF,
d64[prgBase + 0x15].toInt() and 0xFF,
d64[prgBase + 0x16].toInt() and 0xFF,
d64[prgBase + 0x17].toInt() and 0xFF,
d64[prgBase + 0x18].toInt() and 0xFF,
d64[prgBase + 0x19].toInt() and 0xFF,
d64[prgBase + 0x1A].toInt() and 0xFF,
)
))
}
}
}
track = d64[base].toInt() and 0xFF
sector = d64[base + 1].toInt() and 0xFF
}
return result
}
private fun showHeroEditor(slot: DiskSlot) {
val file = diskFile(slot) ?: return
val d64 = try { file.readBytes() } catch (e: Exception) { return }
val heroes = findAllHeroPrgs(d64)
if (heroes.isEmpty()) {
Toast.makeText(this, getString(R.string.no_hero_save), Toast.LENGTH_SHORT).show()
return
}
val labels = arrayOf("Vitalität", "Tapferkeit", "Intelligenz", "Charme",
"Stärke", "Geschicklichkeit", "Angriffswert", "Verteidigung", "Grade")
val isPercent = booleanArrayOf(false, true, true, true, true, true, true, true, false)
val minVals = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 1)
val maxVals = intArrayOf(27, 20, 20, 20, 20, 20, 20, 20, 9)
var selectedIdx = 0
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
fun displayStr(heroIdx: Int, statIdx: Int) =
if (isPercent[statIdx]) "${heroes[heroIdx].values[statIdx] * 5}%"
else "${heroes[heroIdx].values[statIdx]}"
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(16), dp(12), dp(16), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
// Hero selector — always shown (informative even with a single hero)
val spinner = Spinner(this).apply {
adapter = ArrayAdapter(
this@MainActivity,
android.R.layout.simple_spinner_item,
heroes.map { it.name }
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(12) }
}
content.addView(spinner)
// Stat value displays — shared across hero switches
val valueTvs = Array(labels.size) { i ->
TextView(this).apply {
text = displayStr(0, i)
textSize = 13f
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
}
}
for (i in labels.indices) {
val row = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(4) }
}
row.addView(TextView(this).apply {
text = labels[i]
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
layoutParams = android.widget.LinearLayout.LayoutParams(
0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
row.addView(Button(this).apply {
text = ""; textSize = 14f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
setOnClickListener {
val v = heroes[selectedIdx].values
if (v[i] > minVals[i]) { v[i]--; valueTvs[i].text = displayStr(selectedIdx, i) }
}
})
row.addView(valueTvs[i])
row.addView(Button(this).apply {
text = "+"; textSize = 14f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
setOnClickListener {
val v = heroes[selectedIdx].values
if (v[i] < maxVals[i]) { v[i]++; valueTvs[i].text = displayStr(selectedIdx, i) }
}
})
content.addView(row)
}
// Switching heroes refreshes all value views
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p: AdapterView<*>?, v: android.view.View?, pos: Int, id: Long) {
selectedIdx = pos
for (i in labels.indices) valueTvs[i].text = displayStr(selectedIdx, i)
}
override fun onNothingSelected(p: AdapterView<*>?) {}
}
AlertDialog.Builder(this)
.setTitle(getString(R.string.editor_title, slot.label))
.setView(android.widget.ScrollView(this).apply {
addView(content)
setBackgroundColor(Color.parseColor("#1A1A2E"))
})
.setPositiveButton(R.string.editor_save) { _, _ ->
for (hero in heroes) {
val prgBase = hero.sectorOff + 2
for (i in hero.values.indices)
d64[prgBase + 0x12 + i] = hero.values[i].toByte()
}
try {
file.writeBytes(d64)
appendLog("Saved: ${heroes.joinToString { it.name }} (${slot.label})")
} catch (e: Exception) {
appendLog("Save failed: ${slot.label}")
Log.e(TAG, "Hero editor write failed", e)
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
// ---- splash screen ------------------------------------------------------ // ---- splash screen ------------------------------------------------------
private fun dismissSplashDelayed() { private fun dismissSplashDelayed() {
@@ -460,6 +1019,112 @@ class MainActivity : AppCompatActivity() {
// ---- helpers ------------------------------------------------------------ // ---- helpers ------------------------------------------------------------
// ---- save state panel ---------------------------------------------------
private fun stateFile(slot: Int): File {
val dir = getExternalFilesDir(null) ?: filesDir
return File(dir, "SAVESTATE_$slot.VSF")
}
private fun showSaveStatePanel() {
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(16), dp(12), dp(16), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
// Per-slot views that need refreshing after a Save
val timeTvs = arrayOfNulls<TextView>(5)
val loadBtns = arrayOfNulls<Button>(5)
var dialog: AlertDialog? = null
for (slot in 1..5) {
val file = stateFile(slot)
val idx = slot - 1
val row = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(6) }
}
row.addView(TextView(this).apply {
text = getString(R.string.savestate_slot, slot)
textSize = 12f
setTextColor(Color.parseColor("#AAFFAA"))
layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
})
val timeTv = TextView(this).apply {
val exists = file.exists()
text = if (exists) dateFmt.format(Date(file.lastModified()))
else getString(R.string.savestate_empty)
textSize = 11f
setTextColor(if (exists) Color.parseColor("#AAAAAA") else Color.parseColor("#444444"))
layoutParams = android.widget.LinearLayout.LayoutParams(0,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
timeTvs[idx] = timeTv
row.addView(timeTv)
val loadBtn = Button(this).apply {
text = getString(R.string.savestate_load)
textSize = 11f
isEnabled = file.exists()
layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = dp(4)
}
setOnClickListener {
engine.loadState(file.absolutePath)
appendLog("State loaded: slot $slot")
dialog?.dismiss()
}
}
loadBtns[idx] = loadBtn
row.addView(loadBtn)
row.addView(Button(this).apply {
text = getString(R.string.savestate_save)
textSize = 11f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = dp(4)
}
setOnClickListener {
engine.saveState(file.absolutePath)
appendLog("State saved: slot $slot")
// VICE writes asynchronously; refresh the row after one second
mainHandler.postDelayed({
if (file.exists()) {
timeTvs[idx]?.text = dateFmt.format(Date(file.lastModified()))
timeTvs[idx]?.setTextColor(Color.parseColor("#AAAAAA"))
loadBtns[idx]?.isEnabled = true
}
}, 1000)
}
})
content.addView(row)
}
dialog = AlertDialog.Builder(this)
.setTitle(R.string.savestate_title)
.setView(android.widget.ScrollView(this).apply {
addView(content)
setBackgroundColor(Color.parseColor("#1A1A2E"))
})
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showHelp() { private fun showHelp() {
fun load(german: Boolean) = try { fun load(german: Boolean) = try {
assets.open(if (german) "help_de.md" else "help_en.md").bufferedReader().readText() assets.open(if (german) "help_de.md" else "help_en.md").bufferedReader().readText()
@@ -494,6 +1159,34 @@ class MainActivity : AppCompatActivity() {
} }
} }
// Transcript of res/schwert-und-magie-anleitung.pdf, used to answer the
// game's copy-protection prompt ("Nenne Wort X in Zeile Y von Absatz Z").
// Paragraph/line breaks are preserved exactly as printed — see the note
// at the top of the asset file for the counting convention.
private fun showAnleitung() {
val text = try {
assets.open("anleitung.txt").bufferedReader().readText()
} catch (e: Exception) { "Anleitung nicht verfügbar." }
val tv = TextView(this).apply {
this.text = text
typeface = android.graphics.Typeface.MONOSPACE
textSize = 12f
setTextColor(Color.parseColor("#DDDDDD"))
val p = (16 * resources.displayMetrics.density).toInt()
setPadding(p, p, p, p)
}
val scroll = ScrollView(this).apply {
addView(tv)
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
AlertDialog.Builder(this)
.setTitle("Anleitung")
.setView(scroll)
.setPositiveButton("OK", null)
.show()
}
private fun appendLog(entry: String) { private fun appendLog(entry: String) {
val ts = timeFormat.format(Date()) val ts = timeFormat.format(Date())
tvLog.text = "[$ts] $entry\n${tvLog.text}" tvLog.text = "[$ts] $entry\n${tvLog.text}"
@@ -520,7 +1213,8 @@ class CompanionServer(
port: Int, port: Int,
private val timeFormat: SimpleDateFormat, private val timeFormat: SimpleDateFormat,
private val onTimeFetched: (String) -> Unit, private val onTimeFetched: (String) -> Unit,
private val onKey: (String) -> Unit private val onKey: (String) -> Unit,
private val getScreenText: () -> String
) : NanoHTTPD(port) { ) : NanoHTTPD(port) {
override fun serve(session: IHTTPSession): Response { override fun serve(session: IHTTPSession): Response {
@@ -537,9 +1231,40 @@ class CompanionServer(
if (cmd.isNotEmpty()) onKey(cmd) if (cmd.isNotEmpty()) onKey(cmd)
newFixedLengthResponse(Response.Status.OK, "text/plain", "ok") newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
} }
"/screen" -> {
val body = JSONObject().put("text", getScreenText()).toString()
newFixedLengthResponse(Response.Status.OK, "application/json", body)
}
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found") else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
} }
response.addHeader("Access-Control-Allow-Origin", "*") response.addHeader("Access-Control-Allow-Origin", "*")
return response return response
} }
} }
// ---------------------------------------------------------------------------
/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray)
/** Reports cumulative bytes read via [onProgress] as the wrapped stream is consumed. */
private class CountingInputStream(
private val wrapped: InputStream,
private val onProgress: (Long) -> Unit
) : InputStream() {
private var count = 0L
override fun read(): Int {
val b = wrapped.read()
if (b >= 0) { count++; onProgress(count) }
return b
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
val n = wrapped.read(b, off, len)
if (n > 0) { count += n; onProgress(count) }
return n
}
override fun close() = wrapped.close()
}
@@ -14,8 +14,19 @@ else()
message(STATUS "No libvice.a found — building stub (placeholder framebuffer)") message(STATUS "No libvice.a found — building stub (placeholder framebuffer)")
endif() endif()
# Auto-detect whether libnibtools.a was produced by the Gradle buildNibtools task.
set(NIBTOOLS_LIB "${CMAKE_CURRENT_SOURCE_DIR}/nibtools-libs/${ANDROID_ABI}/libnibtools.a")
if(EXISTS "${NIBTOOLS_LIB}")
set(HAVE_NIBTOOLS ON)
message(STATUS "Found ${NIBTOOLS_LIB} — building with disk image download/conversion")
else()
set(HAVE_NIBTOOLS OFF)
message(STATUS "No libnibtools.a found — Download Disks feature will be unavailable")
endif()
# ---- JNI wrapper library ------------------------------------------------- # ---- JNI wrapper library -------------------------------------------------
add_library(vice_jni SHARED vice_jni.c) add_library(vice_jni SHARED vice_jni.c nibconv_jni.c)
target_link_libraries(vice_jni android log c++_shared z OpenSLES) target_link_libraries(vice_jni android log c++_shared z OpenSLES)
@@ -30,7 +41,12 @@ if(HAVE_VICE)
-Wl,--wrap=machine_init -Wl,--wrap=machine_init
-Wl,--wrap=console_init -Wl,--wrap=console_init
-Wl,--wrap=ui_display_drive_led -Wl,--wrap=ui_display_drive_led
-Wl,--wrap=serial_trap_receive) -Wl,--wrap=serial_trap_receive
-Wl,--wrap=event_snapshot_write_module
-Wl,--wrap=event_snapshot_read_module
-Wl,--wrap=machine_trigger_reset
-Wl,--wrap=machine_reset
-Wl,--wrap=maincpu_reset)
target_include_directories(vice_jni PRIVATE target_include_directories(vice_jni PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}" "${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}"
"${VICE_SRC}/src" "${VICE_SRC}/src"
@@ -43,3 +59,12 @@ if(HAVE_VICE)
) )
target_link_libraries(vice_jni "${VICE_LIB}") target_link_libraries(vice_jni "${VICE_LIB}")
endif() endif()
if(HAVE_NIBTOOLS)
target_compile_definitions(vice_jni PRIVATE HAVE_NIBTOOLS=1)
# Redirect nibconv's exit() calls (malformed input) to __wrap_exit in
# nibconv_jni.c, which calls pthread_exit() on the throwaway conversion
# thread instead of killing the process.
target_link_options(vice_jni PRIVATE -Wl,--wrap=exit)
target_link_libraries(vice_jni "${NIBTOOLS_LIB}")
endif()
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Cross-compile nibtools' nibconv (NIB/NBZ -> G64/D64 disk image converter)
# as a static library for Android.
# Called automatically by Gradle; can also be run manually:
# export NDK=/path/to/android-ndk && bash build_nibtools.sh
#
# Output: nibtools-libs/arm64-v8a/libnibtools.a
# nibtools-libs/x86_64/libnibtools.a
#
# Only nibconv's pure file-format conversion code is built — gcr.c, prot.c,
# fileio.c, crc.c, md5.c, lz.c and nibconv.c itself. The hardware-access
# parts of nibtools (nibread/nibwrite, talking to a real 1541 over OpenCBM)
# are not built and not needed; see nibtools_android/opencbm.h for why that
# dependency can be stubbed out instead of vendored.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TARBALL="${SCRIPT_DIR}/../../../../res/nibtools-91344e0ee3.tar.gz"
SRC="${SCRIPT_DIR}/nibtools-src"
ANDROID_SHIM="${SCRIPT_DIR}/nibtools_android"
: "${NDK:?NDK env var must point to the Android NDK root}"
echo "=== build_nibtools.sh ==="
echo " SCRIPT_DIR : ${SCRIPT_DIR}"
echo " TARBALL : ${TARBALL}"
echo " SRC : ${SRC}"
echo " NDK : ${NDK}"
TOOLCHAIN="${NDK}/toolchains/llvm/prebuilt/linux-x86_64"
if [ ! -d "${TOOLCHAIN}" ]; then
echo "ERROR: NDK toolchain not found at ${TOOLCHAIN}" >&2
exit 1
fi
if [ ! -f "${TARBALL}" ]; then
echo "ERROR: nibtools tarball not found at ${TARBALL}" >&2
exit 1
fi
if [ ! -d "${SRC}" ]; then
echo "Unpacking nibtools..."
tar -xzf "${TARBALL}" -C "${SCRIPT_DIR}"
mv "${SCRIPT_DIR}"/nibtools-*/ "${SRC}"
echo "Unpacked to ${SRC}"
fi
# Pure conversion sources — no hardware/OpenCBM access (see header comment above).
SOURCES="gcr.c prot.c fileio.c crc.c md5.c lz.c nibconv.c"
build_abi() {
local ABI="$1"
local HOST="$2"
local API=24
local CC="${TOOLCHAIN}/bin/${HOST}${API}-clang"
local AR="${TOOLCHAIN}/bin/llvm-ar"
local OUT="${SCRIPT_DIR}/nibtools-libs/${ABI}"
local BUILD_DIR="/tmp/nibtools-android-${ABI}"
echo ""
echo "--- ABI: ${ABI} ---"
if [ -f "${OUT}/libnibtools.a" ]; then
echo " libnibtools.a already exists — skipping"
return 0
fi
if [ ! -f "${CC}" ]; then
echo "ERROR: Clang not found at ${CC}" >&2
exit 1
fi
mkdir -p "${BUILD_DIR}" "${OUT}"
for src in ${SOURCES}; do
local obj="${BUILD_DIR}/$(basename "${src}" .c).o"
local extra_defs=""
# nibconv.c's main() is renamed at compile time so it can be linked
# into a shared library (which already has its own JNI entry points)
# without clashing with libc's startup expectations for `main`.
if [ "${src}" = "nibconv.c" ]; then
extra_defs="-Dmain=nibtools_nibconv_main"
fi
"${CC}" -std=c99 -O2 -fPIC ${extra_defs} \
-I "${ANDROID_SHIM}" -I "${SRC}" \
-c "${SRC}/${src}" -o "${obj}"
done
"${AR}" -crs "${OUT}/libnibtools.a" "${BUILD_DIR}"/*.o
echo " Built ${OUT}/libnibtools.a"
}
build_abi "arm64-v8a" "aarch64-linux-android"
build_abi "x86_64" "x86_64-linux-android"
echo ""
echo "=== nibtools build complete ==="
@@ -0,0 +1,76 @@
/*
* JNI bridge to nibtools' nibconv — converts a downloaded NIB/NBZ disk dump
* (raw GCR preservation format) into a G64/D64 image VICE can attach.
*
* Without nibtools source (stub mode, HAVE_NIBTOOLS unset):
* convertDiskImage() always returns false. The "Download Disks" feature
* is unavailable but the rest of the app builds and runs normally.
*
* With nibtools source (HAVE_NIBTOOLS=1, set automatically by CMakeLists.txt
* when nibtools-libs/<abi>/libnibtools.a exists):
* nibtools_nibconv_main() — nibconv's own main(), renamed at compile time
* (see build_nibtools.sh) — runs the real conversion. It is run on a
* throwaway pthread rather than the calling JNI thread: nibconv calls
* exit() on malformed input (e.g. a truncated download), and exit() is
* wrapped below to pthread_exit() instead of killing the whole app
* process. Isolating that to a disposable, JNI-unattached thread means a
* bad conversion just fails this one call instead of detaching the
* caller's JNI thread from underneath it.
*/
#include <jni.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#ifdef HAVE_NIBTOOLS
extern int nibtools_nibconv_main(int argc, char **argv);
struct conv_args {
char *in_path;
char *out_path;
int result;
};
static void *run_nibconv(void *arg) {
struct conv_args *a = (struct conv_args *)arg;
char *argv[3] = { "nibconv", a->in_path, a->out_path };
a->result = nibtools_nibconv_main(3, argv);
return NULL;
}
/* Redirects nibconv's error-path exit() calls so they only terminate the
* throwaway conversion thread above, never the whole process. */
void __wrap_exit(int status) {
pthread_exit((void *)(long)status);
}
#endif /* HAVE_NIBTOOLS */
JNIEXPORT jboolean JNICALL
Java_de_ladkau_schwertundmagieonpebblecompanionapp_C64Engine_convertDiskImage(
JNIEnv *env, jobject thiz, jstring inPath, jstring outPath)
{
(void)thiz;
#ifdef HAVE_NIBTOOLS
const char *inC = (*env)->GetStringUTFChars(env, inPath, NULL);
const char *outC = (*env)->GetStringUTFChars(env, outPath, NULL);
struct conv_args args = { strdup(inC), strdup(outC), -1 };
(*env)->ReleaseStringUTFChars(env, inPath, inC);
(*env)->ReleaseStringUTFChars(env, outPath, outC);
pthread_t t;
pthread_create(&t, NULL, run_nibconv, &args);
pthread_join(t, NULL);
free(args.in_path);
free(args.out_path);
return args.result == 0 ? JNI_TRUE : JNI_FALSE;
#else
(void)env; (void)inPath; (void)outPath;
return JNI_FALSE;
#endif
}
@@ -0,0 +1,18 @@
/*
* Android replacement for nibtools' include/LINUX/mnibarch.h — same content,
* minus the <opencbm.h> include (see opencbm.h stub in this directory).
*/
#ifndef NIBTOOLS_ANDROID_MNIBARCH_H
#define NIBTOOLS_ANDROID_MNIBARCH_H
#include <unistd.h>
#define delay(x) usleep((x) * 1000)
#define msleep(x) delay(x)
#define ARCH_MAINDECL
#define ARCH_SIGNALDECL
typedef unsigned char BYTE;
#endif
@@ -0,0 +1,13 @@
/*
* Stub replacing nibtools' dependency on libopencbm (the real 1541 hardware
* driver). nibconv — the only nibtools program we build — never calls any
* OpenCBM function; CBM_FILE only appears in unused declarations pulled in
* transitively via nibtools.h/ihs.h, so a bare type is enough to satisfy
* the compiler without linking the real library.
*/
#ifndef NIBTOOLS_ANDROID_OPENCBM_STUB_H
#define NIBTOOLS_ANDROID_OPENCBM_STUB_H
typedef int CBM_FILE;
#endif
@@ -36,16 +36,26 @@ static uint32_t g_framebuf[FRAME_W * FRAME_H];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_ready = 0; static int g_ready = 0;
/* Pending disk/reset operations — written by Android thread, consumed by /* Pending disk/reset/snapshot operations — written by Android thread, consumed by
* video_canvas_refresh on the VICE thread (machine_trigger_reset and disk APIs * video_canvas_refresh on the VICE thread (machine_trigger_reset, disk APIs, and
* must be called from the VICE thread to avoid corrupting internal state). * snapshot APIs must be called from the VICE thread to avoid corrupting state).
* g_pending_disk → autostart_disk (resets the machine, used for A-side disks) * g_pending_disk → autostart_disk (resets machine, used for A-side disks)
* g_pending_attach → file_system_attach_disk (hot-swap, used for B-side / hero disks) * g_pending_attach → file_system_attach_disk (hot-swap, B-side / hero disks)
* g_pending_reset → detach disk + hard reset to BASIC prompt */ * g_pending_reset → detach disk + hard reset to BASIC prompt
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER; * g_pending_save_state → machine_write_snapshot (save full machine state)
* g_pending_load_state → machine_read_snapshot (restore full machine state) */
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER;
static char g_pending_disk[512]; static char g_pending_disk[512];
static char g_pending_attach[512]; static char g_pending_attach[512];
static volatile int g_pending_reset = 0; static volatile int g_pending_reset = 0;
static char g_pending_save_state[512];
static char g_pending_load_state[512];
/* Path of the disk image most recently placed in drive 8.
* After machine_read_snapshot the virtual device loses track of the attached
* D64 and falls back to host-filesystem mode. Re-attaching this path
* immediately after a successful snapshot load restores D64 access. */
static char g_current_disk_path[512] = {0};
/* ========================================================================= /* =========================================================================
* VICE integration (compiled only when libvice.a is linked in) * VICE integration (compiled only when libvice.a is linked in)
@@ -67,6 +77,9 @@ extern int __real_console_init(void);
#include "keyboard.h" #include "keyboard.h"
#include "autostart.h" #include "autostart.h"
#include "attach.h" #include "attach.h"
#include "interrupt.h"
#include "charset.h"
#include "mem.h"
/* Drive LED state. /* Drive LED state.
* g_io_frames: bumped by __wrap_serial_trap_receive; keeps LED lit ~100 ms after * g_io_frames: bumped by __wrap_serial_trap_receive; keeps LED lit ~100 ms after
@@ -278,6 +291,83 @@ static const sound_device_t g_android_sound_device = {
/* Stubs for symbols that live in arch-specific or excluded files. */ /* Stubs for symbols that live in arch-specific or excluded files. */
void main_exit(void) {} void main_exit(void) {}
/* event_snapshot_write/read_module: VICE's event-recording system allocates its
* history buffer (start_snapshot.list.base) only when recording is explicitly
* started via event_record_start(). In our headless build recording is never
* started, so the buffer stays NULL and the real implementations crash with a
* null-pointer dereference when called from machine_write_snapshot.
* Replace them with no-ops: snapshots work fine without event playback data. */
struct snapshot_s;
int __wrap_event_snapshot_write_module(struct snapshot_s *s, int event_mode) {
(void)s; (void)event_mode; return 0;
}
int __wrap_event_snapshot_read_module(struct snapshot_s *s) {
(void)s; return 0;
}
/* --wrap=machine_trigger_reset / machine_reset / maincpu_reset: required by the
* linker --wrap flags in CMakeLists.txt; kept as simple pass-throughs so the
* symbols exist. Previously these suppressed resets during snapshot load, but
* the trap-based snapshot load (see load_state_trap below) makes suppression
* unnecessary — snapshot load now runs inside DO_INTERRUPT(IK_TRAP) which
* provides the EXPORT/IMPORT register sync that makes the CPU resume from the
* correct saved PC without any post-load machine reset. */
void __real_machine_trigger_reset(unsigned int reset_mode);
void __wrap_machine_trigger_reset(unsigned int reset_mode) {
__real_machine_trigger_reset(reset_mode);
}
void __real_machine_reset(void);
void __wrap_machine_reset(void) {
__real_machine_reset();
}
void __real_maincpu_reset(void);
void __wrap_maincpu_reset(void) {
__real_maincpu_reset();
}
/* Snapshot load via VICE CPU trap.
*
* machine_read_snapshot() restores CPU registers into maincpu_regs (a global
* struct), but maincpu_mainloop keeps its own stack-local copies (reg_pc,
* reg_a, etc.). If we call machine_read_snapshot() directly from
* video_canvas_refresh(), the loop never re-imports maincpu_regs, so the CPU
* continues from the *old* reg_pc in the *new* (restored) memory — wrong.
*
* The fix: schedule a VICE CPU trap. DO_INTERRUPT(IK_TRAP) in 6510core.c
* calls EXPORT_REGISTERS() before the trap function and IMPORT_REGISTERS()
* after. machine_read_snapshot() inside the trap overwrites maincpu_regs
* with the saved state; IMPORT_REGISTERS() then propagates those values
* (including the saved PC) back into the CPU loop's stack locals. Result:
* the game resumes from exactly the saved execution point. */
/* Both save and load run inside a CPU trap so DO_INTERRUPT(IK_TRAP) calls
* EXPORT_REGISTERS() before the trap function and IMPORT_REGISTERS() after.
*
* For save: EXPORT_REGISTERS() syncs the CPU loop's stack-local reg_pc into
* maincpu_regs before machine_write_snapshot reads it. Without the trap,
* maincpu_regs.pc is stale (last synced at the previous DO_INTERRUPT), so the
* snapshot would record the wrong PC and restore would resume at the wrong
* address — input polling code never reached, buttons appear dead.
*
* For load: machine_read_snapshot overwrites maincpu_regs with the saved state;
* IMPORT_REGISTERS() then propagates the saved PC back into reg_pc so the CPU
* resumes from the correct saved execution point. */
static char g_save_trap_path[512] = {0};
static char g_load_trap_path[512] = {0};
static void save_state_trap(uint16_t address, void *data) {
(void)address;
int r = machine_write_snapshot((const char *)data, 0, 0, 0);
LOGI("machine_write_snapshot → %d", r);
}
static void load_state_trap(uint16_t address, void *data) {
(void)address;
int r = machine_read_snapshot((const char *)data, 0);
LOGI("machine_read_snapshot → %d", r);
}
/* All functions below replace arch/headless/video.c (excluded from libvice.a). */ /* All functions below replace arch/headless/video.c (excluded from libvice.a). */
int video_arch_get_active_chip(void) { return 0; /* VIDEO_CHIP_VICII */ } int video_arch_get_active_chip(void) { return 0; /* VIDEO_CHIP_VICII */ }
@@ -374,16 +464,48 @@ void video_canvas_refresh(video_canvas_t *canvas,
if (g_pending_disk[0] != '\0') { strncpy(autostart_path, g_pending_disk, sizeof(autostart_path)-1); g_pending_disk[0] = '\0'; } if (g_pending_disk[0] != '\0') { strncpy(autostart_path, g_pending_disk, sizeof(autostart_path)-1); g_pending_disk[0] = '\0'; }
if (g_pending_attach[0] != '\0') { strncpy(attach_path, g_pending_attach, sizeof(attach_path)-1); g_pending_attach[0] = '\0'; } if (g_pending_attach[0] != '\0') { strncpy(attach_path, g_pending_attach, sizeof(attach_path)-1); g_pending_attach[0] = '\0'; }
pthread_mutex_unlock(&g_pending_lock); pthread_mutex_unlock(&g_pending_lock);
if (autostart_path[0] != '\0') if (autostart_path[0] != '\0') {
autostart_disk(8, 0, autostart_path, NULL, 0, AUTOSTART_MODE_RUN); autostart_disk(8, 0, autostart_path, NULL, 0, AUTOSTART_MODE_RUN);
strncpy(g_current_disk_path, autostart_path, sizeof(g_current_disk_path) - 1);
}
if (attach_path[0] != '\0') { if (attach_path[0] != '\0') {
int r = file_system_attach_disk(8, 0, attach_path); int r = file_system_attach_disk(8, 0, attach_path);
if (r == 0) { if (r == 0) {
strncpy(g_current_disk_path, attach_path, sizeof(g_current_disk_path) - 1);
g_load_frames = 0; /* clear old cooldown; new autostart will set a fresh one */ g_load_frames = 0; /* clear old cooldown; new autostart will set a fresh one */
g_attach_frames = 75; /* show LED for ~1.5 s immediately after hot-swap */ g_attach_frames = 75; /* show LED for ~1.5 s immediately after hot-swap */
} }
} }
/* Snapshot save / load. */
pthread_mutex_lock(&g_pending_lock);
char save_state_path[512] = {0};
char load_state_path[512] = {0};
if (g_pending_save_state[0] != '\0') {
strncpy(save_state_path, g_pending_save_state, sizeof(save_state_path) - 1);
g_pending_save_state[0] = '\0';
}
if (g_pending_load_state[0] != '\0') {
strncpy(load_state_path, g_pending_load_state, sizeof(load_state_path) - 1);
g_pending_load_state[0] = '\0';
}
pthread_mutex_unlock(&g_pending_lock);
if (save_state_path[0] != '\0') {
strncpy(g_save_trap_path, save_state_path, sizeof(g_save_trap_path) - 1);
g_save_trap_path[sizeof(g_save_trap_path) - 1] = '\0';
interrupt_maincpu_trigger_trap(save_state_trap, g_save_trap_path);
}
if (load_state_path[0] != '\0') {
/* Schedule a VICE CPU trap so machine_read_snapshot runs inside
* DO_INTERRUPT(IK_TRAP). That macro calls EXPORT_REGISTERS() before
* the trap and IMPORT_REGISTERS() after, so the saved PC written into
* maincpu_regs by machine_read_snapshot is picked up by the CPU loop
* immediately — the game resumes from the correct saved execution point. */
strncpy(g_load_trap_path, load_state_path, sizeof(g_load_trap_path) - 1);
g_load_trap_path[sizeof(g_load_trap_path) - 1] = '\0';
interrupt_maincpu_trigger_trap(load_state_trap, g_load_trap_path);
}
/* Drive LED. /* Drive LED.
* VICE's virtual device (vdrive) loads far faster than a real 1541, so * VICE's virtual device (vdrive) loads far faster than a real 1541, so
* autostart_in_progress() is false long before the GDG intro finishes. * autostart_in_progress() is false long before the GDG intro finishes.
@@ -688,6 +810,64 @@ JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) {
return (jint)g_frame_count; return (jint)g_frame_count;
} }
/* Schwert und Magie uploads a custom character set that redefines a handful of
* otherwise-unused PETSCII screen codes (punctuation/graphics glyphs nobody
* needs in German text) to draw umlauts instead. VICE's stock charset tables
* know nothing about this game-specific remap, so those codes must be
* special-cased to the correct UTF-8 character before falling back to the
* standard screencode->PETSCII->ASCII conversion.
* Confirmed by observation — a consecutive run in the unshifted symbol row:
* screencode 0x1B (stock '[') -> ä, 0x1C (stock '£') -> ö, 0x1D (stock ']') -> ü,
* 0x1E (stock '↑') -> ß.
* TODO: add the uppercase variants (Ä, Ö, Ü) once their screen codes are
* identified — likely in the shifted/alternate charset bank. */
static const struct { uint8_t screencode; const char *utf8; } k_char_overrides[] = {
{ 0x1B, "\xC3\xA4" }, /* ä */
{ 0x1C, "\xC3\xB6" }, /* ö */
{ 0x1D, "\xC3\xBC" }, /* ü */
{ 0x1E, "\xC3\x9F" }, /* ß */
};
/* Returns the 40x25 C64 text screen as UTF-8, rows separated by '\n'.
* Reads the fixed default screen address $0400 via mem_read_screen — the same
* helper autostart.c uses to check for KERNAL "READY." text — since this game
* never relocates the VIC-II screen pointer. Screen codes are converted to
* PETSCII then ASCII via VICE's own charset tables (after k_char_overrides is
* checked first); unmappable glyphs (the C64's graphics characters) become '.'. */
JNI_FN(jstring, getScreenText)(JNIEnv *env, jobject obj) {
(void)obj;
#ifdef HAVE_VICE_SRC
/* Worst case every cell is a 2-byte UTF-8 override: 40*25*2 + 25 newlines + NUL. */
char text[40 * 25 * 2 + 25 + 1];
int pos = 0;
for (int row = 0; row < 25; row++) {
for (int col = 0; col < 40; col++) {
uint8_t screencode = mem_read_screen((uint16_t)(0x0400 + row * 40 + col));
const char *override = NULL;
for (size_t i = 0; i < sizeof(k_char_overrides) / sizeof(k_char_overrides[0]); i++) {
if (k_char_overrides[i].screencode == screencode) {
override = k_char_overrides[i].utf8;
break;
}
}
if (override) {
size_t len = strlen(override);
memcpy(text + pos, override, len);
pos += (int)len;
} else {
uint8_t petscii = charset_screencode_to_petcii(screencode);
text[pos++] = (char)charset_p_toascii(petscii, CONVERT_WITHOUT_CTRLCODES);
}
}
text[pos++] = '\n';
}
text[pos] = '\0';
return (*env)->NewStringUTF(env, text);
#else
return (*env)->NewStringUTF(env, "");
#endif
}
/* In thread mode runFrame() is a no-op: VICE drives its own loop. */ /* In thread mode runFrame() is a no-op: VICE drives its own loop. */
JNI_FN(void, runFrame)(JNIEnv *env, jobject obj) { JNI_FN(void, runFrame)(JNIEnv *env, jobject obj) {
(void)env; (void)obj; (void)env; (void)obj;
@@ -717,3 +897,35 @@ JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) {
(void)env; (void)obj; (void)env; (void)obj;
g_sound_enabled = enabled ? 1 : 0; g_sound_enabled = enabled ? 1 : 0;
} }
JNI_FN(jboolean, saveState)(JNIEnv *env, jobject obj, jstring jpath) {
(void)obj;
#ifdef HAVE_VICE_SRC
const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
pthread_mutex_lock(&g_pending_lock);
strncpy(g_pending_save_state, p, sizeof(g_pending_save_state) - 1);
g_pending_save_state[sizeof(g_pending_save_state) - 1] = '\0';
pthread_mutex_unlock(&g_pending_lock);
(*env)->ReleaseStringUTFChars(env, jpath, p);
return JNI_TRUE;
#else
(void)env; (void)jpath;
return JNI_FALSE;
#endif
}
JNI_FN(jboolean, loadState)(JNIEnv *env, jobject obj, jstring jpath) {
(void)obj;
#ifdef HAVE_VICE_SRC
const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
pthread_mutex_lock(&g_pending_lock);
strncpy(g_pending_load_state, p, sizeof(g_pending_load_state) - 1);
g_pending_load_state[sizeof(g_pending_load_state) - 1] = '\0';
pthread_mutex_unlock(&g_pending_lock);
(*env)->ReleaseStringUTFChars(env, jpath, p);
return JNI_TRUE;
#else
(void)env; (void)jpath;
return JNI_FALSE;
#endif
}
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -51,6 +51,28 @@
android:layout_height="12dp" android:layout_height="12dp"
android:layout_marginEnd="4dp" /> android:layout_marginEnd="4dp" />
<Button
android:id="@+id/btn_savestate"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:minWidth="0dp"
android:padding="0dp"
android:text="💾"
android:textSize="16sp" />
<Button
android:id="@+id/btn_anleitung"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:minWidth="0dp"
android:padding="0dp"
android:text="📖"
android:textSize="16sp" />
<Button <Button
android:id="@+id/btn_help" android:id="@+id/btn_help"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
@@ -206,6 +228,10 @@
<Button android:id="@+id/btn_hero_1_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_1_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_1_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -217,6 +243,10 @@
<Button android:id="@+id/btn_hero_2_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_2_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_2_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -228,6 +258,10 @@
<Button android:id="@+id/btn_hero_3_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_3_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_3_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<View <View
@@ -236,14 +270,38 @@
android:background="#333355" android:background="#333355"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp" />
<Button <LinearLayout
android:id="@+id/btn_import_disks"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:orientation="horizontal"
android:text="@string/btn_import_disks" android:layout_marginBottom="4dp">
android:textSize="12sp" />
<Button
android:id="@+id/btn_import_disks"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="@string/btn_import_disks"
android:textAllCaps="false"
android:textSize="11sp"
android:singleLine="true"
android:ellipsize="end" />
<Button
android:id="@+id/btn_download_disks"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/btn_download_disks"
android:textAllCaps="false"
android:textSize="11sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@android:color/transparent"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@android:color/transparent"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

@@ -11,12 +11,30 @@
<!-- Bottom drawer buttons --> <!-- Bottom drawer buttons -->
<string name="btn_import_disks">Disketten importieren</string> <string name="btn_import_disks">Disketten importieren</string>
<string name="btn_download_disks">Disketten herunterladen</string>
<string name="sound_off">Ton: AUS</string> <string name="sound_off">Ton: AUS</string>
<string name="sound_on">Ton: AN</string> <string name="sound_on">Ton: AN</string>
<!-- Disk picker --> <!-- Disk picker -->
<string name="picker_title">SCHWUM-Disketten auswählen</string> <string name="picker_title">SCHWUM-Disketten auswählen</string>
<!-- Disk download (Internet Archive C64 Preservation Project) -->
<string name="disks_download_msg">Lädt alle 8 Folgen-Disketten vom C64 Preservation Project des Internet Archive (von der Community erhaltene historische Disketten-Dumps) herunter und konvertiert sie zur Nutzung hier.</string>
<string name="disks_download_confirm">Herunterladen</string>
<string name="disks_downloading">Lade Diskette %1$d von %2$d…</string>
<string name="disks_download_done">%1$d von %2$d Disketten heruntergeladen</string>
<!-- ROM import -->
<string name="roms_missing_title">C64-ROMs erforderlich</string>
<string name="roms_missing_msg">Diese App enthält keine Commodore-ROM-Dateien (sie sind urheberrechtlich geschützt). Bitte eigenen, legal erworbenen ROM-Dump bereitstellen. Fehlend: %1$s</string>
<string name="roms_import_button">ROMs importieren</string>
<string name="rom_picker_title">kernal-, basic-, chargen-, 1541-ROM-Dateien auswählen</string>
<string name="roms_still_missing">Noch fehlend: %1$s</string>
<string name="roms_download_button">ROMs herunterladen</string>
<string name="roms_download_failed">ROM-Download fehlgeschlagen — Netzwerkverbindung prüfen</string>
<string name="roms_downloading">Wird heruntergeladen…</string>
<string name="roms_downloading_progress">Wird heruntergeladen… %1$d%%</string>
<!-- Load-order warning toast --> <!-- Load-order warning toast -->
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string> <string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
@@ -29,4 +47,17 @@
<!-- Hero disk creation log --> <!-- Hero disk creation log -->
<string name="hero_disk_created">Erstellt: %1$s</string> <string name="hero_disk_created">Erstellt: %1$s</string>
<string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string> <string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string>
<!-- Save state panel -->
<string name="savestate_title">Spielstände</string>
<string name="savestate_slot">Slot %1$d</string>
<string name="savestate_empty">leer</string>
<string name="savestate_save">Speichern</string>
<string name="savestate_load">Laden</string>
<!-- Hero save editor -->
<string name="editor_title">%1$s bearbeiten</string>
<string name="editor_save">Speichern</string>
<string name="no_hero_save">Noch kein Heldspeicherstand auf dieser Diskette</string>
<string name="stat_name">Name</string>
</resources> </resources>
@@ -13,12 +13,30 @@
<!-- Bottom drawer buttons --> <!-- Bottom drawer buttons -->
<string name="btn_import_disks">Import Disks</string> <string name="btn_import_disks">Import Disks</string>
<string name="btn_download_disks">Fetch Disks</string>
<string name="sound_off">Sound: OFF</string> <string name="sound_off">Sound: OFF</string>
<string name="sound_on">Sound: ON</string> <string name="sound_on">Sound: ON</string>
<!-- Disk picker --> <!-- Disk picker -->
<string name="picker_title">Select SCHWUM disk images</string> <string name="picker_title">Select SCHWUM disk images</string>
<!-- Disk download (Internet Archive C64 Preservation Project) -->
<string name="disks_download_msg">Downloads all 8 episode disks from the Internet Archive\'s C64 Preservation Project (community-preserved historical disk dumps) and converts them for use here.</string>
<string name="disks_download_confirm">Download</string>
<string name="disks_downloading">Downloading disk %1$d of %2$d…</string>
<string name="disks_download_done">Downloaded %1$d of %2$d disks</string>
<!-- ROM import -->
<string name="roms_missing_title">C64 ROMs required</string>
<string name="roms_missing_msg">This app does not include Commodore ROM files (they\'re copyrighted). Please supply your own legally-obtained dump. Missing: %1$s</string>
<string name="roms_import_button">Import ROMs</string>
<string name="rom_picker_title">Select kernal, basic, chargen, 1541 ROM files</string>
<string name="roms_still_missing">Still missing: %1$s</string>
<string name="roms_download_button">Download ROMs</string>
<string name="roms_download_failed">ROM download failed — check your network connection</string>
<string name="roms_downloading">Downloading…</string>
<string name="roms_downloading_progress">Downloading… %1$d%%</string>
<!-- Load-order warning toast --> <!-- Load-order warning toast -->
<string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string> <string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string>
@@ -31,4 +49,17 @@
<!-- Hero disk creation log --> <!-- Hero disk creation log -->
<string name="hero_disk_created">Created: %1$s</string> <string name="hero_disk_created">Created: %1$s</string>
<string name="hero_disk_create_failed">Failed to create: %1$s</string> <string name="hero_disk_create_failed">Failed to create: %1$s</string>
<!-- Save state panel -->
<string name="savestate_title">Save States</string>
<string name="savestate_slot">Slot %1$d</string>
<string name="savestate_empty">empty</string>
<string name="savestate_save">Save</string>
<string name="savestate_load">Load</string>
<!-- Hero save editor -->
<string name="editor_title">Edit %1$s</string>
<string name="editor_save">Save</string>
<string name="no_hero_save">No hero save found on this disk yet</string>
<string name="stat_name">Name</string>
</resources> </resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Extracts the C64 and 1541 ROMs needed by vice_jni.c from vice-3.8.tar.gz.
#
# This is a manual/standalone equivalent of the `extractViceRoms` Gradle task
# in app/build.gradle.kts — same source paths, same renaming. Useful for
# inspecting the ROMs outside a full Gradle build (e.g. for the "bring your
# own ROM" flow described in docs/publish.md §0).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARBALL="$SCRIPT_DIR/vice-3.8.tar.gz"
DEST="${1:-$SCRIPT_DIR/roms}"
if [[ ! -f "$TARBALL" ]]; then
echo "error: $TARBALL not found" >&2
exit 1
fi
mkdir -p "$DEST"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
tar -xzf "$TARBALL" -C "$WORK_DIR" \
vice-3.8/data/C64/kernal-901227-03.bin \
vice-3.8/data/C64/basic-901226-01.bin \
vice-3.8/data/C64/chargen-901225-01.bin \
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin"
cp "$WORK_DIR/vice-3.8/data/C64/kernal-901227-03.bin" "$DEST/kernal"
cp "$WORK_DIR/vice-3.8/data/C64/basic-901226-01.bin" "$DEST/basic"
cp "$WORK_DIR/vice-3.8/data/C64/chargen-901225-01.bin" "$DEST/chargen"
cp "$WORK_DIR/vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" "$DEST/1541"
echo "Extracted kernal, basic, chargen, 1541 to $DEST"
+9 -2
View File
@@ -24,10 +24,17 @@
}, },
"messageKeys": [ "messageKeys": [
"TIME", "TIME",
"COMMAND" "COMMAND",
"SCREEN"
], ],
"resources": { "resources": {
"media": [] "media": [
{
"type": "bitmap",
"name": "IMAGE_SPLASH",
"file": "splash.png"
}
]
} }
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -1,15 +1,39 @@
#include <pebble.h> #include <pebble.h>
enum { enum {
KEY_TIME = 0, // Inbound: current time string from companion app KEY_TIME = 0, // Inbound: current time string from companion app (unused — see KEY_SCREEN)
KEY_COMMAND = 1 // Outbound: button name sent to companion app KEY_COMMAND = 1, // Outbound: button name sent to companion app
KEY_SCREEN = 2 // Inbound: C64 text screen (40x25, ASCII, rows separated by '\n')
}; };
static Window *s_main_window; #define SCREEN_FONT FONT_KEY_GOTHIC_24
static TextLayer *s_time_layer;
static TextLayer *s_hint_layer;
static char s_time_buffer[32] = "Waiting..."; // Curated navigation-only key set for the SELECT wheel. Movement in this
// game is mostly done with number keys, plus RETURN/SPACE to confirm.
static const char *const k_wheel_items[] = {
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "RETURN", "SPACE"
};
#define WHEEL_ITEM_COUNT ((int)(sizeof(k_wheel_items) / sizeof(k_wheel_items[0])))
static Window *s_main_window;
static ScrollLayer *s_scroll_layer;
static TextLayer *s_screen_layer;
static Window *s_wheel_window;
static TextLayer *s_wheel_label_layer;
static TextLayer *s_wheel_hint_layer;
static int s_wheel_index = 0;
#define SPLASH_DURATION_MS 1800
static Window *s_splash_window;
static BitmapLayer *s_splash_bitmap_layer;
static GBitmap *s_splash_bitmap;
// Worst case 40*25 cells at 2 UTF-8 bytes each (umlaut overrides) + 25
// row-separator newlines + NUL, matching the companion app's getScreenText()
// output (see vice_jni.c).
static char s_screen_buffer[2080] = "Waiting for C64 screen...";
static void send_key_to_phone(const char *key_str) { static void send_key_to_phone(const char *key_str) {
DictionaryIterator *iter; DictionaryIterator *iter;
@@ -19,69 +43,183 @@ static void send_key_to_phone(const char *key_str) {
app_message_outbox_send(); app_message_outbox_send();
} }
// Resize the text layer and scroll layer's content size to fit s_screen_buffer
// at the current font/width. Must run whenever the buffer's text changes —
// the C64 screen content (and therefore wrapped height) varies frame to frame.
static void update_screen_layout(void) {
GRect bounds = layer_get_bounds(scroll_layer_get_layer(s_scroll_layer));
GFont font = fonts_get_system_font(SCREEN_FONT);
GRect measure_box = GRect(0, 0, bounds.size.w, 4000);
GSize content_size = graphics_text_layout_get_content_size(
s_screen_buffer, font, measure_box, GTextOverflowModeWordWrap, GTextAlignmentLeft);
// Floor content height at the viewport height so short text doesn't shrink
// the scrollable area below what's visible.
int16_t height = content_size.h > bounds.size.h ? content_size.h : bounds.size.h;
layer_set_frame(text_layer_get_layer(s_screen_layer), GRect(0, 0, bounds.size.w, height));
scroll_layer_set_content_size(s_scroll_layer, GSize(bounds.size.w, height));
}
static void inbox_received_callback(DictionaryIterator *iterator, void *context) { static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
Tuple *time_tuple = dict_find(iterator, KEY_TIME); Tuple *screen_tuple = dict_find(iterator, KEY_SCREEN);
if (time_tuple && time_tuple->type == TUPLE_CSTRING) { if (screen_tuple && screen_tuple->type == TUPLE_CSTRING) {
snprintf(s_time_buffer, sizeof(s_time_buffer), "%s", time_tuple->value->cstring); snprintf(s_screen_buffer, sizeof(s_screen_buffer), "%s", screen_tuple->value->cstring);
text_layer_set_text(s_time_layer, s_time_buffer); text_layer_set_text(s_screen_layer, s_screen_buffer);
layer_mark_dirty(text_layer_get_layer(s_time_layer)); update_screen_layout();
layer_mark_dirty(text_layer_get_layer(s_screen_layer));
} }
} }
static void up_click_handler(ClickRecognizerRef recognizer, void *context) { static void update_wheel_label(void) {
send_key_to_phone("UP"); text_layer_set_text(s_wheel_label_layer, k_wheel_items[s_wheel_index]);
} }
static void down_click_handler(ClickRecognizerRef recognizer, void *context) { static void wheel_up_click_handler(ClickRecognizerRef recognizer, void *context) {
send_key_to_phone("DOWN"); s_wheel_index = (s_wheel_index - 1 + WHEEL_ITEM_COUNT) % WHEEL_ITEM_COUNT;
update_wheel_label();
}
static void wheel_down_click_handler(ClickRecognizerRef recognizer, void *context) {
s_wheel_index = (s_wheel_index + 1) % WHEEL_ITEM_COUNT;
update_wheel_label();
}
// Sends the highlighted item and pops back to the screen mirror. The BACK
// button is left unsubscribed so its default behavior (pop the window) acts
// as "cancel" for free.
static void wheel_select_click_handler(ClickRecognizerRef recognizer, void *context) {
send_key_to_phone(k_wheel_items[s_wheel_index]);
window_stack_pop(true);
}
static void wheel_click_config_provider(void *context) {
window_single_click_subscribe(BUTTON_ID_UP, wheel_up_click_handler);
window_single_click_subscribe(BUTTON_ID_DOWN, wheel_down_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, wheel_select_click_handler);
}
static void wheel_window_load(Window *window) {
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
s_wheel_label_layer = text_layer_create(GRect(0, bounds.size.h / 2 - 24, bounds.size.w, 48));
text_layer_set_font(s_wheel_label_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK));
text_layer_set_text_alignment(s_wheel_label_layer, GTextAlignmentCenter);
update_wheel_label();
layer_add_child(window_layer, text_layer_get_layer(s_wheel_label_layer));
s_wheel_hint_layer = text_layer_create(GRect(0, bounds.size.h - 30, bounds.size.w, 30));
text_layer_set_font(s_wheel_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14));
text_layer_set_text_alignment(s_wheel_hint_layer, GTextAlignmentCenter);
text_layer_set_text(s_wheel_hint_layer, "UP/DOWN spin - SELECT send");
layer_add_child(window_layer, text_layer_get_layer(s_wheel_hint_layer));
}
static void wheel_window_unload(Window *window) {
text_layer_destroy(s_wheel_label_layer);
text_layer_destroy(s_wheel_hint_layer);
} }
static void select_click_handler(ClickRecognizerRef recognizer, void *context) { static void select_click_handler(ClickRecognizerRef recognizer, void *context) {
send_key_to_phone("SELECT"); window_stack_push(s_wheel_window, true);
} }
static void click_config_provider(void *context) { // Passed to scroll_layer_set_callbacks(): the ScrollLayer's own click config
window_single_click_subscribe(BUTTON_ID_UP, up_click_handler); // provider calls this after wiring UP/DOWN to scroll, so we only need to add
window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler); // SELECT here — UP/DOWN stay dedicated to panning the text view.
static void scroll_click_config_provider(void *context) {
window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler); window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
} }
static void splash_window_load(Window *window) {
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
s_splash_bitmap = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_SPLASH);
s_splash_bitmap_layer = bitmap_layer_create(bounds);
bitmap_layer_set_bitmap(s_splash_bitmap_layer, s_splash_bitmap);
// Source image is roughly square and smaller than every target screen, so
// center it without stretching rather than filling/distorting the frame.
bitmap_layer_set_alignment(s_splash_bitmap_layer, GAlignCenter);
bitmap_layer_set_background_color(s_splash_bitmap_layer, GColorBlack);
layer_add_child(window_layer, bitmap_layer_get_layer(s_splash_bitmap_layer));
}
static void splash_window_unload(Window *window) {
bitmap_layer_destroy(s_splash_bitmap_layer);
gbitmap_destroy(s_splash_bitmap);
}
// main_window is pushed underneath splash_window at startup (see init()), so
// popping splash here just reveals it — the stack is never empty, which
// matters because removing the last window on the stack kills the app.
static void splash_timeout_handler(void *data) {
window_stack_remove(s_splash_window, true);
}
static void main_window_load(Window *window) { static void main_window_load(Window *window) {
Layer *window_layer = window_get_root_layer(window); Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer); GRect bounds = layer_get_bounds(window_layer);
s_time_layer = text_layer_create(GRect(0, 55, bounds.size.w, 50)); s_scroll_layer = scroll_layer_create(bounds);
text_layer_set_text(s_time_layer, s_time_buffer); scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK)); scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks) {
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter); .click_config_provider = scroll_click_config_provider
layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); });
s_hint_layer = text_layer_create(GRect(0, 130, bounds.size.w, 30)); // The 40-column C64 grid is not preserved — the companion app's text
text_layer_set_text(s_hint_layer, "UP / DN / SEL"); // already has '\n' per row, but at this font size lines reflow
text_layer_set_font(s_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14)); // (word-wrap) rather than line up like the original screen.
text_layer_set_text_alignment(s_hint_layer, GTextAlignmentCenter); s_screen_layer = text_layer_create(GRect(0, 0, bounds.size.w, bounds.size.h));
layer_add_child(window_layer, text_layer_get_layer(s_hint_layer)); text_layer_set_text(s_screen_layer, s_screen_buffer);
text_layer_set_font(s_screen_layer, fonts_get_system_font(SCREEN_FONT));
text_layer_set_text_alignment(s_screen_layer, GTextAlignmentLeft);
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(s_screen_layer));
update_screen_layout();
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
} }
static void main_window_unload(Window *window) { static void main_window_unload(Window *window) {
text_layer_destroy(s_time_layer); text_layer_destroy(s_screen_layer);
text_layer_destroy(s_hint_layer); scroll_layer_destroy(s_scroll_layer);
} }
static void init(void) { static void init(void) {
s_main_window = window_create(); s_main_window = window_create();
window_set_click_config_provider(s_main_window, click_config_provider);
window_set_window_handlers(s_main_window, (WindowHandlers) { window_set_window_handlers(s_main_window, (WindowHandlers) {
.load = main_window_load, .load = main_window_load,
.unload = main_window_unload .unload = main_window_unload
}); });
window_stack_push(s_main_window, true); window_stack_push(s_main_window, true);
s_wheel_window = window_create();
window_set_click_config_provider(s_wheel_window, wheel_click_config_provider);
window_set_window_handlers(s_wheel_window, (WindowHandlers) {
.load = wheel_window_load,
.unload = wheel_window_unload
});
s_splash_window = window_create();
window_set_window_handlers(s_splash_window, (WindowHandlers) {
.load = splash_window_load,
.unload = splash_window_unload
});
// Pushed on top of the already-present main_window, not in place of an
// empty stack — see the note on splash_timeout_handler below.
window_stack_push(s_splash_window, true);
app_timer_register(SPLASH_DURATION_MS, splash_timeout_handler, NULL);
app_message_register_inbox_received(inbox_received_callback); app_message_register_inbox_received(inbox_received_callback);
app_message_open(128, 64); // Inbox must hold the full 40x25 screen text (up to ~2080 bytes with UTF-8
// umlaut overrides) plus dictionary overhead; outbox only ever carries a
// short button-name string.
app_message_open(2200, 64);
} }
static void deinit(void) { static void deinit(void) {
window_destroy(s_splash_window);
window_destroy(s_wheel_window);
window_destroy(s_main_window); window_destroy(s_main_window);
} }
@@ -2,6 +2,7 @@ var SERVER = 'http://127.0.0.1:8888';
var KEY_TIME = 0; var KEY_TIME = 0;
var KEY_COMMAND = 1; var KEY_COMMAND = 1;
var KEY_SCREEN = 2;
function httpGet(url) { function httpGet(url) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -12,15 +13,15 @@ function httpGet(url) {
xhr.send(); xhr.send();
} }
function sendTimeToWatch() { function sendScreenToWatch() {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('GET', SERVER + '/time', true); xhr.open('GET', SERVER + '/screen', true);
xhr.onload = function() { xhr.onload = function() {
if (xhr.status === 200) { if (xhr.status === 200) {
try { try {
var data = JSON.parse(xhr.responseText); var data = JSON.parse(xhr.responseText);
var msg = {}; var msg = {};
msg[KEY_TIME] = data.time; msg[KEY_SCREEN] = data.text;
Pebble.sendAppMessage( Pebble.sendAppMessage(
msg, msg,
function() {}, function() {},
@@ -32,13 +33,13 @@ function sendTimeToWatch() {
} }
}; };
xhr.onerror = function() { xhr.onerror = function() {
console.log('[SuM] GET /time failed - is the companion app running?'); console.log('[SuM] GET /screen failed - is the companion app running?');
}; };
xhr.send(); xhr.send();
} }
Pebble.addEventListener('ready', function() { Pebble.addEventListener('ready', function() {
setInterval(sendTimeToWatch, 1000); setInterval(sendScreenToWatch, 1000);
}); });
Pebble.addEventListener('appmessage', function(e) { Pebble.addEventListener('appmessage', function(e) {
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Builds and pushes the release build environment image (build-image/Dockerfile)
# to the container registry. The Gitea Actions release workflow pulls this
# image to run dist.sh. Bump build-image/VERSION whenever the Dockerfile
# changes so the workflow can pin a stable tag.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Building $REGISTRY_IMAGE:$VERSION =="
docker build \
-t "$REGISTRY_IMAGE:$VERSION" \
-t "$REGISTRY_IMAGE:latest" \
-f "$ROOT/build-image/Dockerfile" \
"$ROOT/build-image"
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION"
docker push "$REGISTRY_IMAGE:latest"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION"
+66
View File
@@ -0,0 +1,66 @@
# Build environment for schwert_und_magie_on_pebble release artifacts:
# Android SDK/NDK (companion app) + Pebble SDK (watch app), matching the
# versions pinned in app/build.gradle.kts and validated on the maintainer's
# dev machine. Rebuild and push with ../build-image.sh whenever a version
# below, or the companion app's ndkVersion/compileSdk, changes.
#
# Does NOT contain the release keystore or any secrets — those are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM eclipse-temurin:21-jdk-jammy
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
ARG ANDROID_PLATFORM=android-36
ARG ANDROID_BUILD_TOOLS=36.1.0
ARG ANDROID_NDK=30.0.14904198
ARG PEBBLE_TOOL_VERSION=5.0.35
ARG PEBBLE_SDK_CORE_VERSION=4.9.169
ARG NODE_VERSION=24.16.0
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk \
ANDROID_NDK_HOME=/opt/android-sdk/ndk/30.0.14904198 \
PATH=/root/.local/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${PATH}
# git/unzip/curl/jq: checkout, SDK downloads, and the release workflow's
# calls to the Gitea API (create release, upload assets).
# python3-venv: pebble-tool's `sdk install` creates a venv per SDK version.
# dos2unix/autoconf/automake/pkg-config/xa65/build-essential/gettext: host
# tools required by the companion app's build_vice.sh (see that file's own
# preflight check) to cross-compile VICE via autotools before NDK clang
# takes over for the actual target compilation.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git unzip tar xz-utils python3 python3-venv file jq \
dos2unix autoconf automake pkg-config xa65 build-essential gettext \
&& rm -rf /var/lib/apt/lists/*
# --- Android SDK: cmdline-tools, platform, build-tools, NDK ---
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK}" \
>/dev/null
# Node.js: `pebble sdk install` below runs `npm install` for the SDK-core's
# bundled webpack tooling — it does not bring its own node/npm, only the JS
# deps themselves. Version matches what's validated on the maintainer's
# machine; sdk-core's own bundled arm-none-eabi toolchain needs nothing extra.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# --- Pebble SDK: pebble-tool + sdk-core ---
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& /root/.local/bin/uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
&& /root/.local/bin/pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
1
Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Builds release artifacts for both apps and drops them in dist/, named
# schwert-und-magie-<version>.{aab,apk,pbw}.
#
# Version comes from the current git tag (vX.Y.Z) by default — push a tag to
# drive a release. Override with VERSION=1.2.3 ./dist.sh, or just run it
# untagged for a local dev build (gets a 0.0.0-dev+<sha> placeholder version).
#
# Android output requires a release signing config — see docs/publish.md §2.1-2.3
# (keystore.properties + release.keystore in SchwertUndMagieOnPebbleCompanionApp/).
# Without it, Gradle still produces an unsigned/debug-signed build; that's not
# fatal (useful for local testing) so it's a warning, not a hard stop.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
DIST="$ROOT/dist"
COMPANION="$ROOT/SchwertUndMagieOnPebbleCompanionApp"
WATCH="$ROOT/SchwertUndMagieOnPebbleWatchApp"
GRADLE_KTS="$COMPANION/app/build.gradle.kts"
WATCH_PKG_JSON="$WATCH/package.json"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
warn() { echo "WARNING: $*" >&2; }
echo "== Preflight checks =="
command -v pebble >/dev/null 2>&1 \
|| fail "pebble CLI not found in PATH (needed to build the watch app)"
[ -x "$COMPANION/gradlew" ] \
|| fail "$COMPANION/gradlew missing or not executable"
SDK_DIR="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
if [ -f "$COMPANION/local.properties" ]; then
LOCAL_SDK="$(sed -n 's/^sdk\.dir=//p' "$COMPANION/local.properties")"
[ -n "$LOCAL_SDK" ] && SDK_DIR="$LOCAL_SDK"
fi
[ -n "$SDK_DIR" ] && [ -d "$SDK_DIR" ] \
|| fail "Android SDK not found (checked local.properties sdk.dir, \$ANDROID_SDK_ROOT, \$ANDROID_HOME)"
NDK_VERSION="$(sed -n 's/.*ndkVersion *= *"\(.*\)".*/\1/p' "$GRADLE_KTS")"
[ -n "$NDK_VERSION" ] \
|| fail "could not read ndkVersion from app/build.gradle.kts"
[ -d "$SDK_DIR/ndk/$NDK_VERSION" ] \
|| fail "NDK $NDK_VERSION not installed under $SDK_DIR/ndk (Android Studio > SDK Manager > SDK Tools > NDK side by side)"
[ -f "$COMPANION/res/vice-3.8.tar.gz" ] \
|| fail "$COMPANION/res/vice-3.8.tar.gz missing (VICE source tarball required by the buildVice Gradle task)"
compgen -G "$COMPANION/res/nibtools-*.tar.gz" >/dev/null \
|| fail "$COMPANION/res/nibtools-*.tar.gz missing (nibtools source tarball required by the buildNibtools Gradle task)"
# Signing config: missing/broken keystore.properties still builds (unsigned),
# so warn here and rely on the post-build apksigner check for the real answer.
if [ ! -f "$COMPANION/keystore.properties" ]; then
warn "keystore.properties not found — release build will likely be unsigned (see docs/publish.md §2.1-2.3)"
else
STORE_FILE="$(sed -n 's/^storeFile=//p' "$COMPANION/keystore.properties")"
if [ -z "$STORE_FILE" ] || [ ! -f "$COMPANION/$STORE_FILE" ]; then
warn "keystore.properties found but its storeFile ('$STORE_FILE') does not exist — release build will likely be unsigned"
fi
fi
echo "== Resolving version =="
if [ -z "${VERSION:-}" ]; then
if TAG="$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null)"; then
VERSION="${TAG#v}"
else
VERSION="0.0.0-dev+$(git rev-parse --short HEAD)"
warn "HEAD is not on a vX.Y.Z tag — building placeholder version $VERSION (push a tag to drive a real release version)"
fi
fi
[[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]] \
|| fail "VERSION '$VERSION' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)"
VERSION_CODE=$(( ${BASH_REMATCH[1]} * 10000 + ${BASH_REMATCH[2]} * 100 + ${BASH_REMATCH[3]} ))
echo "Version: $VERSION (Android versionCode $VERSION_CODE)"
# Patch versions into the tracked source files for this build only, then
# restore them — dist.sh must never leave the working tree dirty.
restore_version_files() {
git -C "$ROOT" checkout -- "$GRADLE_KTS" "$WATCH_PKG_JSON" 2>/dev/null || true
}
trap restore_version_files EXIT
sed -i \
-e "s/versionCode = [0-9]\+/versionCode = $VERSION_CODE/" \
-e "s/versionName = \"[^\"]*\"/versionName = \"$VERSION\"/" \
"$GRADLE_KTS"
sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$WATCH_PKG_JSON"
mkdir -p "$DIST"
echo "== Android companion app =="
cd "$COMPANION"
./gradlew bundleRelease assembleRelease
cp -f app/build/outputs/bundle/release/app-release.aab "$DIST/schwert-und-magie-$VERSION.aab"
cp -f app/build/outputs/apk/release/app-release.apk "$DIST/schwert-und-magie-$VERSION.apk"
echo "== Pebble watch app =="
cd "$WATCH"
pebble build
cp -f build/SchwertUndMagieOnPebbleWatchApp.pbw "$DIST/schwert-und-magie-$VERSION.pbw"
echo "== Verifying APK signature =="
APK="$DIST/schwert-und-magie-$VERSION.apk"
APKSIGNER="$(compgen -G "$SDK_DIR/build-tools/*/apksigner" | sort -V | tail -1 || true)"
if [ -z "$APKSIGNER" ]; then
warn "apksigner not found under $SDK_DIR/build-tools — could not verify APK signature"
elif ! "$APKSIGNER" verify "$APK" >/dev/null 2>&1; then
warn "$APK is UNSIGNED (failed apksigner verification) — not installable/publishable as-is"
elif "$APKSIGNER" verify --print-certs "$APK" 2>/dev/null | grep -qi "CN=Android Debug"; then
warn "$APK is signed with the Android debug cert, not the release keystore"
fi
echo "== Done =="
ls -la "$DIST"
+162
View File
@@ -0,0 +1,162 @@
# Architecture
This document describes the current runtime architecture of the two apps in this
repo: the **Pebble watch app** and the **Android companion app**. For build/install
commands, see `CLAUDE.md`.
## 1. System overview
Three processes cooperate across two devices. **Core for Pebble** is a separate
app on the phone (not part of this repo) that bridges Bluetooth AppMessage traffic
to a JS runtime; our companion app talks to it only via loopback HTTP.
![System overview diagram](diagrams/system-overview.png)
## 2. Pebble watch app
`SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c` is a
single-file C watchapp built around three `Window`s on a shared stack, plus
`src/pkjs/index.js` running inside Core for Pebble.
![Watch app window stack state diagram](diagrams/watch-window-stack.png)
- **Splash** — `BitmapLayer` showing `resources/splash.png`, centered, black
backdrop. Pushed *on top of* the already-pushed Main window at startup (not
in place of an empty stack — removing the last window on the stack kills the
app), then removed by an `AppTimer` after 1.8s.
- **Main** — a `ScrollLayer` wrapping a `TextLayer` that mirrors the C64 text
screen. UP/DOWN are claimed entirely by the ScrollLayer's built-in click
config (pan only); SELECT is added via `scroll_layer_set_callbacks()`'s
`click_config_provider` hook and opens the wheel. Content height is
recomputed via `graphics_text_layout_get_content_size()` every time new
screen text arrives, since wrapped height varies frame to frame.
- **Wheel** — a single large `TextLayer` cycling through a curated,
navigation-only key set: `1``9`, `0`, `RETURN`, `SPACE` (movement in this
game is mostly done with number keys). UP/DOWN rotate the index, SELECT
sends the highlighted label and pops back to Main, BACK cancels for free
(default window-pop behavior, left unsubscribed).
### AppMessage protocol
| Key | Value | Direction | Payload |
|---|---|---|---|
| `TIME` | 0 | — | Unused (legacy; originally the clock, replaced by `SCREEN`) |
| `COMMAND` | 1 | watch → phone | Wheel item label: one of `1`..`9`, `0`, `RETURN`, `SPACE` |
| `SCREEN` | 2 | phone → watch | UTF-8 C64 screen text, 40×25 cells, `\n` per row |
Numeric keys are hardcoded identically in the C app and `index.js` — symbolic
key resolution from `package.json`'s `messageKeys` is unreliable with Core for
Pebble. The watch's AppMessage inbox is opened at 2200 bytes to fit the
worst-case screen payload (40×25 cells × up to 2 UTF-8 bytes for umlaut
overrides, + 25 newlines).
## 3. Android companion app
![Companion app thread diagram](diagrams/companion-threads.png)
- **Main/UI thread** — `MainActivity`'s `Choreographer.postFrameCallback` loop
drives rendering: on every hardware vsync it calls `display.captureFrame(engine)`
(copies VICE's 320×200 ARGB framebuffer into a Bitmap) and `invalidate()`.
VICE itself runs continuously and asynchronously in its own thread, decoupled
from this vsync sampling. Touch input (`C64KeyboardView`, disk drawer
buttons) and NanoHTTPD callbacks (marshaled via `mainHandler`) also run here.
- **VICE thread** (`vice_thread` in `vice_jni.c`) — runs `main_program()`
`maincpu_mainloop()`, VICE's own CPU/VICII loop. `video_canvas_refresh()` is
our hook into this loop, called once per rendered region: it drains
mutex-guarded pending-operation queues (disk autostart/attach, hard reset,
snapshot save/load) written from the UI or HTTP threads, then renders into
`g_framebuf`.
- **NanoHTTPD worker thread(s)** — `CompanionServer` (in `MainActivity.kt`)
serves `/time`, `/key?cmd=`, `/screen` to Core for Pebble's JS. `onKey` and
`getScreenText` call directly into the JNI layer from this thread (see
§5 on tolerated races).
- **stdout/stderr reader threads** — pipe VICE's redirected stdout/stderr to
Logcat under tag `ViceJNI`, prefixed `VICE: `.
- **OpenSL ES callback thread** — pulls PCM samples from a ring buffer filled
by VICE's registered `android` sound driver.
### Key source files
| File | Role |
|---|---|
| `MainActivity.kt` | UI, Choreographer render loop, NanoHTTPD server, disk drawer, watch key wheel → `injectKey` mapping |
| `C64Engine.kt` | JNI external-function declarations + C64 keyboard matrix constants |
| `C64DisplayView.kt` | Double-buffered View blitting the 320×200 ARGB framebuffer |
| `C64KeyboardView.kt` | On-screen virtual C64 keyboard (multi-touch, sticky shift) |
| `vice_jni.c` | VICE integration: thread management, video/sound drivers, pending-op queues, snapshot CPU-trap dispatch, `getScreenText()` |
## 4. Data flows
### 4.1 Screen mirror (VICE → watch)
![Screen mirror sequence diagram](diagrams/screen-mirror-flow.png)
`getScreenText()` reads C64 screen RAM at the fixed default address `$0400`
(same assumption `autostart.c` makes when checking for KERNAL "READY." text —
this game never relocates the VIC-II screen pointer) and converts each
screencode → PETSCII → ASCII via VICE's own `charset.c` tables. The game
uploads a **custom character set** that redefines a consecutive run of
otherwise-unused screencodes to draw German umlauts; a small override table in
`getScreenText()` catches these and emits proper UTF-8 before falling through
to the standard conversion:
| Screencode | Stock glyph | Overridden to |
|---|---|---|
| `0x1B` | `[` | ä |
| `0x1C` | `£` | ö |
| `0x1D` | `]` | ü |
| `0x1E` | `↑` | ß |
### 4.2 On-screen keyboard input (phone touch → VICE)
![On-screen keyboard input sequence diagram](diagrams/keyboard-input-flow.png)
Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are
pressed/released together.
### 4.3 Watch key wheel input (watch → VICE)
![Watch key wheel input sequence diagram](diagrams/watch-wheel-input-flow.png)
### 4.4 Snapshot save/load (CPU-trap register sync)
![Snapshot save/load CPU-trap sequence diagram](diagrams/snapshot-save-load-flow.png)
Both save and load **must** run inside a CPU trap. `maincpu_mainloop()` keeps
CPU registers as stack-local variables (`reg_pc`, `reg_a`, ...), syncing them
with the global `maincpu_regs` struct only via `EXPORT_REGISTERS()` /
`IMPORT_REGISTERS()` inside `DO_INTERRUPT`. Calling `machine_write_snapshot`/
`machine_read_snapshot` directly from `video_canvas_refresh()` (outside a trap)
would read/write a stale `maincpu_regs.pc` — the snapshot would record (or
restore) the wrong program counter, leaving the CPU executing from the wrong
address after a load even though screen/CIA/SID state all looked correct.
### 4.5 Disk load / attach / reset
`loadDisk()` (full reset + autostart, used for A-side episode disks) and
`attachDisk()` (hot-swap, used for B-side/hero disks) both just write a path
into a mutex-guarded pending buffer; `video_canvas_refresh()` drains it on the
VICE thread and calls `autostart_disk()` or `file_system_attach_disk()`
accordingly — disk and reset APIs, like snapshot APIs, must only be called
from the VICE thread.
## 5. Tolerated cross-thread races
Two JNI calls are invoked directly from non-VICE threads with no locking:
`injectKey()` (writes the keyboard matrix from the UI thread *or* an HTTP
worker thread) and `getScreenText()` (reads screen RAM from an HTTP worker
thread). Both are deliberate: a keyboard matrix write or a screen-text read
racing with the VICE thread can produce at most one stale byte for one frame,
which self-corrects on the next poll/keypress — acceptable for display and
input purposes. This is a different category from §4.4: snapshot register
sync is correctness-critical (a wrong PC corrupts execution permanently), so
it goes through the CPU trap; keyboard/display reads are not, so they don't.
---
Diagrams are rendered PNGs under `diagrams/`; each has a matching `.mmd`
Mermaid source in the same folder. To regenerate one after editing its source:
```bash
npx @mermaid-js/mermaid-cli -i diagrams/<name>.mmd -o diagrams/<name>.png -b white -s 3
```
+222
View File
@@ -0,0 +1,222 @@
# Debugging guide
How to get diagnostic output from both apps, plus a catalog of real bugs hit
during development — each written as symptom → diagnosis → fix, since that's
the order you'll actually encounter them in. See `CLAUDE.md` for build/install
commands and `docs/architecture.md` for what each component does.
## 1. Getting logs
### Watch app
```bash
pebble logs --phone <phone-ip>
```
Streams both the C app's `APP_LOG`/`text_layer` state changes and the
PebbleKit JS `console.log` output from inside Core for Pebble, interleaved.
This is the only place JS errors (failed `fetch`, JSON parse errors,
`sendAppMessage` failures) show up — they are otherwise silent on the watch.
### Companion app
```bash
adb logcat | grep -E 'ViceJNI|SuM'
```
- Tag `ViceJNI` (`vice_jni.c`) carries two kinds of lines:
- Direct `LOGI`/`LOGE` calls from our own JNI code, e.g.
`machine_read_snapshot → 0`.
- Everything VICE itself prints to stdout/stderr, redirected through a pipe
and re-emitted with a `VICE: ` prefix by `stderr_reader_thread`.
**These two categories run on different threads** — see §3.3, it's a useful
fact when you need to tell whether code is actually running on the VICE
thread.
- Tag `SuM` (`MainActivity.kt`, `TAG` constant) carries UI-level events: watch
key presses, disk load/attach results, HTTP server start/stop.
## 2. Watch app pitfalls
### 2.1 App shows splash, then exits back to the watch's launcher
**Symptom:** the splash window appears, the timer fires, and instead of
transitioning to the main screen the app quits entirely (back to the watch
face or app list) — not a crash log, just gone.
**Diagnosis:** the Pebble window stack was briefly empty. The original splash
implementation did:
```c
static void splash_timeout_handler(void *data) {
window_stack_push(s_main_window, true); // queued, not necessarily applied yet
window_stack_remove(s_splash_window, false);
}
```
Pebble's own docs warn: *"If there are no windows for the app left on the
stack, the app will be killed by the system, shortly."* Push and remove are
not guaranteed to be atomic with respect to each other from the app's
perspective, so this push-then-remove ordering can race.
**Fix:** never let the stack reach zero windows, even momentarily. Push the
*replacement* window first and keep it on the stack permanently underneath
the transient one, so the timeout handler only ever pops — it never has to
push-and-remove in the same breath:
```c
// init(): main_window pushed first, splash pushed on top of it.
window_stack_push(s_main_window, true);
...
window_stack_push(s_splash_window, true);
// timeout handler: main_window is still underneath, so the stack never empties.
static void splash_timeout_handler(void *data) {
window_stack_remove(s_splash_window, true);
}
```
### 2.2 AppMessage silently never arrives / `sendAppMessage failed`
**Symptom:** PebbleKit JS logs a `sendAppMessage failed` error, or nothing at
all and the watch's `TextLayer` never updates.
**Diagnosis checklist:**
- **Inbox too small.** The watch's `app_message_open(inboxSize, outboxSize)`
inbox must fit the *largest* payload plus dictionary overhead. The screen
mirror payload is up to ~2080 bytes (40×25 cells, up to 2 UTF-8 bytes each
for umlaut overrides, + 25 newlines) — the inbox is opened at 2200 bytes to
leave headroom. If you add a new larger payload type, bump this.
- **Symbolic keys.** `{ 'SCREEN': value }` does not reliably resolve via
`package.json`'s `messageKeys` under Core for Pebble. Always use the
hardcoded numeric key, identically defined in both the C `enum` and
`index.js` (`KEY_SCREEN = 2` in both places).
- **`localhost` vs `127.0.0.1`.** Core for Pebble's JS runtime does not
resolve `localhost``pkjs`'s `fetch`/`XMLHttpRequest` calls must target
`127.0.0.1:8888`.
### 2.3 Pebble build fails with a Python traceback mentioning `relpath`
**Symptom:**
```
AttributeError: 'NoneType' object has no attribute 'relpath'
File ".../waflib/extras/process_sdk_resources.py", line 22, in find_most_specific_filename
```
**Diagnosis:** the Pebble SDK's build system hardcodes the resource folder
name to `resources/` (`bld.path.find_node("resources")` in
`pebble_sdk.py`) — *not* `res/`. If `package.json`'s `resources.media[].file`
points at a file that doesn't exist under a real top-level `resources/`
folder, `resources_node` resolves to `None` and the path-join call inside the
SDK throws this traceback instead of a normal "file not found" error.
**Fix:** keep all `media` resource files (PNGs, etc.) under a top-level
`resources/` directory, never `res/`.
## 3. Companion app / VICE pitfalls
### 3.1 Snapshot restore: screen looks right, but buttons do nothing
**Symptom:** after `loadState()`, the saved screen renders correctly (memory,
VIC-II, SID, CIA state are all genuinely restored), but the game never
responds to keyboard/joystick input again.
**Diagnosis:** `maincpu_mainloop()` keeps the 6510's registers as
**stack-local C variables** (`reg_pc`, `reg_a`, ...), not in the global
`maincpu_regs` struct. They're synced only inside `DO_INTERRUPT()`'s
`EXPORT_REGISTERS()` / `IMPORT_REGISTERS()` macros. Calling
`machine_write_snapshot()` / `machine_read_snapshot()` directly from
`video_canvas_refresh()` (i.e. outside an interrupt) means:
- **Save** records whatever `maincpu_regs.pc` happened to hold from the *last*
interrupt — stale, not the actual current PC.
- **Load** writes the saved PC into `maincpu_regs.pc`, but the CPU loop keeps
running from its own unrelated `reg_pc` — the restored PC never takes
effect.
The CPU ends up executing from a PC that has nothing to do with the saved
game state. Screen/CIA/SID content looks fine because *those* are restored
correctly; only the actual instruction pointer is wrong, which silently
breaks "the game keeps running but never processes input" without crashing.
**Fix:** route both save and load through `interrupt_maincpu_trigger_trap()`,
so the snapshot call happens inside `DO_INTERRUPT(IK_TRAP)` and gets the
`EXPORT_REGISTERS()`/`IMPORT_REGISTERS()` sync for free. See
`save_state_trap()` / `load_state_trap()` in `vice_jni.c`, and
`docs/architecture.md` §4.4 for the full sequence diagram.
**How this was actually diagnosed:** by comparing thread IDs in `adb logcat`.
`LOGI("machine_read_snapshot → %d", r)` (called directly from inside the trap
function) appeared on the same TID as `android_sound_close()`'s Android
audio-stack log lines — i.e. the VICE/CPU thread. The `VICE: ...`-prefixed
lines (from `stderr_reader_thread`) appeared on a *different* TID. That
confirmed the trap really was running on the CPU thread, which ruled out a
threading mistake and pointed at the register-sync logic instead.
### 3.2 `VICE: Sync reset` in the log is not a C64 reset
**Symptom:** `VICE: Sync reset` appears in logcat right after a snapshot
load, looking like the machine just got reset (which would explain broken
input — but doesn't, in fact).
**Diagnosis:** this line comes from `vsync_suspend_speed_eval()` in
`vsync.c`, triggered when the sound device closes/reopens during the
snapshot's `sound_snapshot_finish()` call. It resets internal frame-timing
statistics only — it is unrelated to `machine_reset()`/`maincpu_reset()` and
does not touch CPU or memory state. Don't chase it as the cause of a
post-restore bug; it's noise.
### 3.3 `VICE: Error - T64 snapshot support is not implemented`
**Symptom:** this warning appears on every snapshot load, but
`machine_read_snapshot()` still returns `0` (success).
**Diagnosis:** snapshots are saved with `save_disks=0` (disk management is
handled by our own UI, not VICE's), so the drive module writes a minimal
entry on save. On restore, `drive_snapshot_read_module()` logs this warning
while parsing that minimal entry but still returns success — harmless.
### 3.4 Disk/reset/snapshot APIs must only be called from the VICE thread
**Symptom:** intermittent corruption or crashes after calling `loadDisk()`,
`attachDisk()`, `resetMachine()`, `saveState()`, or `loadState()` directly
from the calling (UI or HTTP) thread.
**Diagnosis:** all five funnel into mutex-guarded pending-path buffers
(`g_pending_disk`, `g_pending_attach`, `g_pending_reset`,
`g_pending_save_state`, `g_pending_load_state`) that `video_canvas_refresh()`
drains once per rendered frame, on the VICE thread. This is intentional —
`autostart_disk()`, `file_system_attach_disk()`, `machine_trigger_reset()`,
and the snapshot calls are not safe to call cross-thread.
**Fix:** never call these JNI entry points' underlying VICE functions
directly from Kotlin; always go through the existing pending-queue pattern
in `vice_jni.c` if adding a new one.
### 3.5 `injectKey()` / `getScreenText()` have no locking — is that a bug?
No — this is deliberate, not a bug to "fix" if you notice it while reading
the code. Both are called directly from non-VICE threads (the UI thread for
on-screen keyboard taps, an HTTP worker thread for watch input and the screen
mirror poll) without synchronizing with the VICE thread. Worst case, a race
produces one stale keyboard-matrix bit or one stale screen byte for a single
frame, which self-corrects on the next call. This is a different category
from §3.1/§3.4: those are correctness-critical (a wrong PC or a cross-thread
VICE API call corrupts state permanently), this isn't.
## 4. Isolating the watch UI from the companion app
To iterate on watch-side rendering/layout without a live companion app or
phone, simulate the AppMessage payload directly against an emulator instance:
```bash
pebble install --emulator emery --vnc
pebble send-app-message --emulator emery --vnc --string 2="<40x25 test text>"
pebble screenshot --vnc --no-open /tmp/screen.png
```
(Key `2` is `KEY_SCREEN` — see the protocol table in `docs/architecture.md`.)
This was how word-wrap and clipping behavior were verified across screen
sizes (emery vs. basalt) before wiring up the real HTTP/VICE pipeline. Prefer
testing on a real watch once the companion app is in the loop — the emulator
is best for fast, isolated layout iteration, not for verifying end-to-end
behavior.
+16
View File
@@ -0,0 +1,16 @@
graph TD
Main["Main/UI thread<br/>Choreographer vsync loop, touch events"]
Vice["VICE thread<br/>main_program() → maincpu_mainloop()"]
Http["NanoHTTPD worker thread(s)<br/>one per request"]
StdoutT["stdout reader thread"]
StderrT["stderr reader thread"]
Audio["OpenSL ES callback thread"]
Main -->|"captureFrame(): reads g_framebuf"| Vice
Main -->|"injectKey(): writes keyboard matrix"| Vice
Http -->|"onKey → injectWatchKey → injectKey"| Vice
Http -->|"getScreenText(): reads C64 RAM"| Vice
Vice -->|"writes g_framebuf, drains pending queues"| Main
Vice -->|stdout/stderr pipes| StdoutT
Vice -->|stdout/stderr pipes| StderrT
Vice -->|sound ring buffer| Audio
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+11
View File
@@ -0,0 +1,11 @@
sequenceDiagram
participant User
participant KB as C64KeyboardView
participant Main as MainActivity
participant JNI as vice_jni.c
participant VICE as VICE keyboard matrix
User->>KB: touch down/up on key
KB->>Main: onKeyEvent(key, pressed)
Main->>JNI: engine.injectKey(code, pressed) (per code, for composites)
JNI->>VICE: keyboard_set_keyarr(row, col, pressed)
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+16
View File
@@ -0,0 +1,16 @@
sequenceDiagram
participant VICE as VICE thread
participant JNI as vice_jni.c
participant HTTP as NanoHTTPD /screen
participant JS as PebbleKit JS
participant Watch as Watch app (KEY_SCREEN)
loop every 1s
JS->>HTTP: GET /screen
HTTP->>JNI: getScreenText()
JNI->>VICE: mem_read_screen($0400..$07E7)
JNI-->>HTTP: UTF-8 text (40x25, \n per row)
HTTP-->>JS: {"text": "..."}
JS->>Watch: AppMessage KEY_SCREEN
Watch->>Watch: update ScrollLayer/TextLayer
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+15
View File
@@ -0,0 +1,15 @@
sequenceDiagram
participant UI as MainActivity (save/load button)
participant Pending as g_pending_save_state / g_pending_load_state
participant Refresh as video_canvas_refresh (VICE thread)
participant Trap as interrupt_maincpu_trigger_trap
participant CPU as 6510core.c DO_INTERRUPT(IK_TRAP)
UI->>Pending: saveState(path) / loadState(path) [mutex-guarded]
Refresh->>Pending: drain pending path next frame
Refresh->>Trap: schedule save_state_trap / load_state_trap
CPU->>CPU: EXPORT_REGISTERS()
CPU->>Trap: run trap function
Trap->>Trap: machine_write_snapshot() / machine_read_snapshot()
CPU->>CPU: IMPORT_REGISTERS()
Note over CPU: reg_pc now matches the saved/restored maincpu_regs.pc
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+21
View File
@@ -0,0 +1,21 @@
graph LR
subgraph Watch["Pebble Time 2 (watch)"]
WatchC["Watch app (C)<br/>splash / main / wheel windows"]
end
subgraph Phone["Android phone"]
CFP["Core for Pebble<br/>(PebbleKit JS runtime, separate app)"]
subgraph Companion["Companion app process"]
HTTP["NanoHTTPD server :8888"]
UI["MainActivity / UI<br/>C64DisplayView, C64KeyboardView"]
JNI["JNI bridge<br/>vice_jni.c"]
VICE["VICE C64 core<br/>(own pthread)"]
end
end
WatchC <-->|Bluetooth AppMessage| CFP
CFP <-->|HTTP loopback 127.0.0.1:8888| HTTP
HTTP --> UI
UI --> JNI
JNI <--> VICE
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+15
View File
@@ -0,0 +1,15 @@
sequenceDiagram
participant Watch
participant JS as PebbleKit JS
participant HTTP as NanoHTTPD /key
participant Main as MainActivity
participant JNI as vice_jni.c
Watch->>Watch: SELECT opens wheel, UP/DOWN rotate, SELECT confirms
Watch->>JS: AppMessage KEY_COMMAND = "LABEL"
JS->>HTTP: GET /key?cmd=LABEL
HTTP->>Main: onKey(cmd)
Main->>Main: watchKeyMap[cmd] → codes
Main->>JNI: injectKey(code, true) for each code
Main->>Main: postDelayed 80ms
Main->>JNI: injectKey(code, false) for each code
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+5
View File
@@ -0,0 +1,5 @@
stateDiagram-v2
[*] --> Splash
Splash --> Main: 1800ms timer\n(window_stack_remove splash)
Main --> Wheel: SELECT\n(push wheel)
Wheel --> Main: SELECT (send + pop)\nor BACK (cancel, pop)
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+401
View File
@@ -0,0 +1,401 @@
# Publishing guide
Steps to generate signing credentials and publish both apps. See
`docs/architecture.md` for what each app does and `CLAUDE.md` for build
commands.
## 1. .gitignore
Keystore files and credential properties must never be committed. Already
added to `.gitignore`:
```
*.jks
*.keystore
keystore.properties
```
If you generate a keystore with a different name/extension, add that
specific path too — don't rely on a broad glob you might forget to check.
## 2. Android companion app → Google Play
### 2.1 Generate an upload keystore
```bash
keytool -genkeypair -v \
-keystore SchwertUndMagieOnPebbleCompanionApp/release.keystore \
-alias sum-release \
-keyalg RSA -keysize 2048 -validity 10000
```
`keytool` will prompt for a keystore password, a key password (can be the
same), and your name/org details for the certificate (these become public
metadata in the signed APK, not secret). Store the keystore file and both
passwords in a password manager — **losing this keystore means you can never
publish an update to the same Play Store listing again** under the same app;
Google cannot recover or reset it for you.
### 2.2 Store credentials in a gitignored properties file
Create `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` (already
gitignored, never commit it):
```properties
storeFile=release.keystore
storePassword=<keystore password>
keyAlias=sum-release
keyPassword=<key password>
```
### 2.3 Wire the signing config
Add to `SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts`, near the
top:
```kotlin
import java.util.Properties
val keystoreProps = Properties().apply {
rootProject.file("keystore.properties").takeIf { it.exists() }
?.reader()?.use { load(it) }
}
```
Inside the `android { }` block:
```kotlin
signingConfigs {
create("release") {
keystoreProps["storeFile"]?.let { storeFile = file(it as String) }
storePassword = keystoreProps["storePassword"] as String?
keyAlias = keystoreProps["keyAlias"] as String?
keyPassword = keystoreProps["keyPassword"] as String?
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
// ...existing isMinifyEnabled / proguardFiles
}
}
```
This degrades gracefully (null signing config values) on any machine without
`keystore.properties` — CI or a contributor's checkout — rather than failing
the whole Gradle configuration.
### 2.4 Build the release bundle
```bash
./dist.sh # builds both apps, copies signed artifacts into dist/
```
This runs `./gradlew bundleRelease assembleRelease` in
`SchwertUndMagieOnPebbleCompanionApp/` and `pebble build` in
`SchwertUndMagieOnPebbleWatchApp/`, then copies the outputs to
`dist/schwert-und-magie.{aab,apk,pbw}`.
Before building, the script hard-stops (nonzero exit, nothing written to
`dist/`) if the `pebble` CLI, `gradlew`, the Android SDK, the NDK version
pinned in `app/build.gradle.kts`, or the VICE/nibtools source tarballs are
missing — these are required for the build to succeed at all. It warns but
still builds if `keystore.properties` (§2.1-2.3) is missing or points at a
nonexistent keystore file, and checks the resulting APK's signature with
`apksigner` afterward, warning if it's unsigned or debug-signed. In practice
an incomplete signing config also makes Gradle's own `signReleaseBundle` task
fail outright, so `dist/` won't be overwritten with an unusable artifact
either way — but don't rely on that as the primary check; heed the warning.
Play Store requires the **AAB** (`schwert-und-magie.aab`), not the APK — Play
re-packages per-device APKs from it (including ABI splits, so `arm64-v8a`/
`x86_64` native VICE libraries each ship only to matching devices). The APK
(`schwert-und-magie.apk`) is for direct sideload distribution (§2.6 below)
and F-Droid-style testing, not Play Store upload.
### 2.5 Play Console setup (one-time, per app listing)
1. Enroll in the Google Play Developer program (one-time account fee).
2. Create the app in Play Console, set its package name
(`de.ladkau.schwertundmagieonpebblecompanionapp`) — this is permanent.
3. **App content**: privacy policy URL, content rating questionnaire, data
safety form (declare what data the app collects — this app's only network
activity is the loopback HTTP server talking to Core for Pebble, so
"no data collected/shared" likely applies, but fill out the form yourself).
4. **Store listing**: title, short/full description, icon (512×512 PNG),
feature graphic (1024×500), phone screenshots (min 2, current device's
actual aspect ratio).
5. Enroll in **Play App Signing** when prompted on first upload — Google
re-signs your AAB with its own key for distribution; your upload keystore
(§2.1) only needs to be kept for *future uploads to this listing*, not for
the keys end users' devices actually trust.
6. Upload the AAB to an **internal testing** track first, verify the install
works on a real device, then promote to closed/open testing or production.
### 2.6 Versioning for future releases
Comes from the git tag automatically — see §4. Pushing `vX.Y.Z` (or running
`VERSION=X.Y.Z ./dist.sh` locally) patches `versionCode` (derived as
`X*10000 + Y*100 + Z`, which strictly increases as long as X.Y.Z itself does)
and `versionName` in `app/build.gradle.kts` at build time, then reverts them —
no manual edit needed.
## 3. Android companion app → F-Droid
F-Droid is a community-run FOSS app catalogue that builds apps from source on
its own infrastructure and signs them with its own key. The companion app is a
good fit: it is open source, ships no proprietary binaries in the repo, and the
copyrighted ROM and disk files are handled entirely at runtime by the user.
### 3.1 Inclusion policy check
F-Droid's key requirement is that everything needed to build the app is in the
repo and is free/open-source. Verify:
- VICE 3.8 source is GPLv2 (tarball in `res/`). ✓
- nibtools source is GPLv2 (tarball in `res/`). ✓
- No prebuilt binaries committed (`.a`/`.so` files are build outputs in
`.gitignore`). ✓
- No non-free SDKs or closed-source dependencies in `build.gradle.kts`. ✓
- C64 ROMs and game disks are not bundled — user-supplied at runtime. ✓
### 3.2 Prepare the fdroiddata metadata file
F-Droid apps are registered by adding a YAML file to the
[fdroiddata](https://gitlab.com/fdroid/fdroiddata) repository. Fork it, then
create `metadata/de.ladkau.schwertundmagieonpebblecompanionapp.yml`:
```yaml
Categories:
- Games
License: GPL-2.0-or-later
AuthorName: Matthias Ladkau
SourceCode: https://github.com/<your-repo>
IssueTracker: https://github.com/<your-repo>/issues
AutoName: Schwert und Magie on Pebble
RepoType: git
Repo: https://github.com/<your-repo>
Builds:
- versionName: '1.0'
versionCode: 1
commit: <git-tag-or-sha>
subdir: SchwertUndMagieOnPebbleCompanionApp
gradle:
- release
ndk: 30.0.14904198
prebuild:
- bash app/src/main/jni/build_vice.sh
- bash app/src/main/jni/build_nibtools.sh
AutoUpdateMode: None
UpdateCheckMode: None
```
Key points:
- `subdir` points to the Gradle project root (where `gradlew` lives).
- `gradle: [release]` tells F-Droid to run `./gradlew assembleRelease`.
- `ndk` must match the `ndkVersion` in `app/build.gradle.kts`
(`30.0.14904198`). F-Droid's build server installs the requested NDK version
automatically.
- `prebuild` runs the VICE and nibtools cross-compilation before Gradle
invokes CMake. The `NDK` environment variable is set by F-Droid's build
server, matching what `build_vice.sh` and `build_nibtools.sh` expect.
- F-Droid signs with its own key, so `keystore.properties` is not needed on
the build server. The signing config in `build.gradle.kts` degrades
gracefully when the file is absent.
### 3.3 Test the build locally with fdroid-server
Before submitting, reproduce the F-Droid build environment locally:
```bash
# Install fdroid-server (Debian/Ubuntu)
sudo apt install fdroidserver
# In the fdroiddata checkout:
fdroid build de.ladkau.schwertundmagieonpebblecompanionapp --latest
```
This runs the build inside a Docker container that mirrors the F-Droid build
server. A successful local build means the merge request is very likely to
pass.
### 3.4 Submit the merge request
Push your `metadata/` addition to your fdroiddata fork and open a merge
request against `gitlab.com/fdroid/fdroiddata`. The F-Droid team reviews the
metadata and, if the build passes on their infrastructure, merges it and
includes the app in the next index update (published roughly weekly).
### 3.5 Versioning for F-Droid updates
Each new release requires a new `Builds` entry in the metadata file with an
incremented `versionCode` and the corresponding git commit or tag.
`versionCode` must match what that tag produces — `X*10000 + Y*100 + Z` for
tag `vX.Y.Z` (§2.6, §4).
## 4. Automated releases via Gitea Actions
Pushing a tag `vX.Y.Z` builds both apps and publishes a Gitea Release with
`schwert-und-magie-X.Y.Z.{aab,apk,pbw}` attached — no local `./dist.sh` run
needed. The tag is the only source of truth for the version; nothing needs to
be bumped in source files beforehand (`dist.sh` patches `versionCode`/
`versionName`/`package.json` at build time and reverts them — see §4.4 for
running the same thing locally).
The whole toolchain (Android SDK/NDK, Pebble SDK) lives in
`build-image/Dockerfile`, built and pushed to a container registry by
`build-image.sh`. This keeps the setup portable: the runner just needs Docker
and pulls that image, so it isn't tied to any one machine's local toolchain
install and can be moved or re-registered elsewhere without touching this
repo's build scripts.
### 4.1 Build environment image
`build-image/Dockerfile` bakes in the Android SDK/NDK and Pebble SDK versions
pinned in `app/build.gradle.kts`, matching what's validated for local builds.
It does **not** contain the release keystore — that's injected at job runtime
from Actions secrets (§4.3), never baked into the image.
```bash
cp registry.env.example registry.env # fill in your registry credentials
./build-image.sh # builds + pushes :latest and the pinned VERSION tag
```
Rebuild and push whenever `build-image/Dockerfile` changes (e.g. a Pebble SDK
or Android NDK version bump) — bump `build-image/VERSION` first so the tag is
meaningful. The workflow (§4.2) pulls `:latest` by default; if you need a
release to be reproducible against an exact toolchain image, pin the `image:`
line in `.gitea/workflows/release.yml` to the versioned tag instead.
### 4.2 Runner setup (one-time)
1. Enable Actions for the repo: repo Settings → Actions → enable, if not
already on by default for this Gitea instance.
2. Generate a runner registration token: Site Admin → Actions → Runners (or
the repo/org-scoped equivalent) → "Create new runner".
3. On any machine with Docker that can reach both your Gitea instance and
your container registry:
```bash
# https://gitea.com/gitea/act_runner — grab the latest release binary
./act_runner register --no-interactive \
--instance <your gitea URL> --token <token> \
--name <runner-name> --labels self-hosted:docker://node:20-bookworm
./act_runner daemon
```
The image after `docker://` in `--labels` is only a fallback for jobs that
don't specify their own `container:` — irrelevant here since
`.gitea/workflows/release.yml` always pins its own image, but the runner
still needs a Docker-executor label registered to use that executor at
all. The label name itself (`self-hosted`) must match `runs-on:` in
`.gitea/workflows/release.yml` — edit both together if you rename it.
4. The runner's Docker daemon needs pull access to the registry — run
`docker login <registry>` once on that machine with the same credentials
as `registry.env`.
### 4.3 Repo secrets
Settings → Actions → Secrets, add:
| Secret | Value |
|---|---|
| `RELEASE_KEYSTORE_B64` | `base64 -w0 SchwertUndMagieOnPebbleCompanionApp/release.keystore` |
| `RELEASE_KEYSTORE_PROPERTIES` | the full contents of `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` (§2.2) |
`secrets.GITEA_TOKEN` (used to create the release and upload assets) is
Gitea's own auto-generated per-job token — nothing to create or add yourself.
`.gitea/workflows/release.yml` requests `contents: write` explicitly so
release creation works regardless of this instance's default Actions
permission mode.
### 4.4 Cutting a release
```bash
git tag v1.2.3
git push origin v1.2.3
```
Watch the run under the repo's Actions tab. On success, the release appears
under the repo's Releases page with the three versioned artifacts attached.
To build the same versioned artifacts locally without pushing a tag (e.g. to
test before releasing):
```bash
VERSION=1.2.3 ./dist.sh
```
## 5. Pebble watch app → Rebble app store / direct distribution
The official Pebble app store shut down years ago; the community-run
**Rebble** store is the closest equivalent today, alongside the 2025 Core
Devices relaunch of Pebble hardware. Check Rebble's current developer portal
directly for their exact submission flow and requirements — that's outside
this repo and changes independently of it.
### 5.1 Build the artifact
```bash
./dist.sh # also builds the Android side; see §2.4
```
or, to build just the watch app:
```bash
cd SchwertUndMagieOnPebbleWatchApp
pebble build # produces build/SchwertUndMagieOnPebbleWatchApp.pbw
```
`dist.sh` copies the result to `dist/schwert-und-magie.pbw`. The `.pbw` is
the complete distributable — it bundles all target platforms
(`aplite`/`basalt`/`chalk`/`diorite`/`emery`/`flint`/`gabbro`) declared in
`package.json`. There is no signing step analogous to Android; Pebble apps
are not cryptographically signed by the developer.
### 5.2 Direct distribution (no store)
Anyone with the `.pbw` file and Core for Pebble installed can sideload it —
this is the lowest-friction path and doesn't depend on any third party's
store being operational. This watch app is tightly coupled to the companion
app's HTTP bridge (see `docs/architecture.md` §1), so it's not really
meaningful as a standalone listing anyway — distribute both together.
### 5.3 Store submission (if Rebble's process applies)
Expect to need: an app icon resource (not yet configured in `package.json` —
there's no `icon`/menu-icon entry currently, only the in-app `IMAGE_SPLASH`
bitmap), a short description, and screenshots per platform. Generate
screenshots the same way used during development:
```bash
pebble install --emulator emery --vnc
pebble screenshot --vnc --no-open docs/screenshots/emery.png
```
(repeat per target platform you want a store screenshot for).
### 5.4 Versioning
Comes from the git tag automatically — see §4. `dist.sh` patches
`SchwertUndMagieOnPebbleWatchApp/package.json`'s `version` field at build
time and reverts it afterward; no manual edit needed.
## 6. Pre-publish checklist
- [ ] Release keystore generated, passwords saved in a password manager, both
gitignored (§1, §2.1)
- [ ] `keystore.properties` exists locally and is **not** tracked by git
- [ ] `./dist.sh` (or a tag push, §4) succeeds and produces
`dist/schwert-und-magie-<version>.{aab,apk,pbw}`
- [ ] AAB installs/runs on a real device (test via `bundletool` or Play
internal testing)
- [ ] Play Console store listing content complete (icon, screenshots,
privacy policy, content rating, data safety form)
- [ ] F-Droid metadata YAML created and `fdroid build` passes locally (§3)
- [ ] `.pbw` sideloads and runs correctly against the signed companion app
build
+6
View File
@@ -0,0 +1,6 @@
# Copy to registry.env (gitignored, never commit the real values) and fill in.
# Used by build-image.sh to push the build environment image to cr.ladkau.de.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/schwert-und-magie/builder
REGISTRY_USER=
REGISTRY_PASSWORD=