Compare commits
19 Commits
958ab77c58
..
v0.9.9
| Author | SHA1 | Date | |
|---|---|---|---|
| f65b9be1df | |||
| 5ac145bff6 | |||
| e07258c8bd | |||
| 8881241fd8 | |||
| ad887c8bc8 | |||
| 2117043e31 | |||
| 84c47136df | |||
| 23ad9ebc32 | |||
| f3f7442849 | |||
| 73c6c90c8d | |||
| 3705935ca4 | |||
| eee068a500 | |||
| daefed88b1 | |||
| aaa6e772a7 | |||
| 89c99b50e2 | |||
| 463c85e62f | |||
| b9153ac973 | |||
| 0f814eb134 | |||
| 755e7758e4 |
@@ -0,0 +1,32 @@
|
||||
# Build context for build-image/Dockerfile. The context is the repo root
|
||||
# (see build-image.sh) so the Gradle-cache-warming stage can COPY in the
|
||||
# companion app's real project files — everything else is excluded to keep
|
||||
# the context small and to make sure secrets never reach the Docker daemon.
|
||||
.git
|
||||
dist
|
||||
docs
|
||||
versions
|
||||
SchwertUndMagieOnPebbleWatchApp
|
||||
|
||||
# Companion app: only the Gradle project files are needed (see the
|
||||
# gradle-cache-warm stage) — not generated build output or the VICE/nibtools
|
||||
# source tarballs (buildVice/buildNibtools are excluded from that stage's
|
||||
# gradle invocation, so they're never unpacked there).
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/build
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/.cxx
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-src
|
||||
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-libs
|
||||
SchwertUndMagieOnPebbleCompanionApp/res/*.tar.gz
|
||||
SchwertUndMagieOnPebbleCompanionApp/.gradle
|
||||
SchwertUndMagieOnPebbleCompanionApp/.idea
|
||||
SchwertUndMagieOnPebbleCompanionApp/local.properties
|
||||
SchwertUndMagieOnPebbleCompanionApp/build
|
||||
SchwertUndMagieOnPebbleCompanionApp/captures
|
||||
|
||||
# Secrets — must never reach the Docker daemon, even unused.
|
||||
registry.env
|
||||
**/keystore.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
# Runner defaults `run:` steps to `sh`, which doesn't understand
|
||||
# `set -o pipefail` used below — force bash explicitly.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
# Must match a label your act_runner is registered with. The runner's
|
||||
# own default label-image is irrelevant here since `container:` below
|
||||
# overrides the actual build image per-job.
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
# Lets the runner pull a private image without a manual `docker login`
|
||||
# on the runner host — see docs/publish.md §4.3.
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
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 }}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
|
||||
# Reuse an existing release for this tag instead of failing outright
|
||||
# (curl -f exit 22) if a prior run already created it — e.g. a retry
|
||||
# after a later step failed. (No -f here: a 404 for "no release yet"
|
||||
# is expected, not an error — it just leaves .id empty below.)
|
||||
RELEASE_ID="$(curl -s "$API/releases/tags/$TAG" -H "$AUTH" | jq -r '.id // empty')"
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
RELEASE_ID="$(curl -sf -X POST "$API/releases" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
|
||||
| jq -r .id)"
|
||||
fi
|
||||
|
||||
for f in dist/*; do
|
||||
NAME="$(basename "$f")"
|
||||
# Same idempotency concern for assets: a retry re-uploading a name
|
||||
# that's already attached would 409, so replace it instead.
|
||||
EXISTING_ID="$(curl -sf "$API/releases/$RELEASE_ID/assets" -H "$AUTH" \
|
||||
| jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id')"
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sf -X DELETE "$API/releases/$RELEASE_ID/assets/$EXISTING_ID" -H "$AUTH"
|
||||
fi
|
||||
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "$AUTH" -F "attachment=@$f"
|
||||
done
|
||||
|
||||
- name: Publish to dl.ladkau.de
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||
chmod 600 ~/.ssh/dl_sftp_key
|
||||
BATCH="$(mktemp)"
|
||||
{
|
||||
echo "-mkdir files/schwert-und-magie"
|
||||
for f in dist/*; do
|
||||
echo "put $f files/schwert-und-magie/$(basename "$f")"
|
||||
done
|
||||
} > "$BATCH"
|
||||
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-b "$BATCH" uploader@dl.ladkau.de
|
||||
@@ -6,7 +6,7 @@
|
||||
.cxx
|
||||
local.properties
|
||||
.lock*
|
||||
|
||||
.idea
|
||||
/SchwertUndMagieOnPebbleCompanionApp/local.properties
|
||||
/SchwertUndMagieOnPebbleCompanionApp/.idea/caches
|
||||
/SchwertUndMagieOnPebbleCompanionApp/.idea/libraries
|
||||
@@ -20,5 +20,18 @@ local.properties
|
||||
/SchwertUndMagieOnPebbleCompanionApp/app/.cxx
|
||||
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
|
||||
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
|
||||
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-src
|
||||
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-libs
|
||||
|
||||
/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
|
||||
|
||||
@@ -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).
|
||||
|
||||
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) all ship inside the VICE 3.8
|
||||
tarball at `res/vice-3.8.tar.gz` and are bundled in the APK under `app/src/main/assets/`.
|
||||
They are copied automatically to the external files dir on first launch — no user action needed.
|
||||
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) are copyrighted and are
|
||||
**not** bundled in the APK. On first launch, `MainActivity` checks the app's
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| `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/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
|
||||
|
||||
@@ -123,3 +128,34 @@ same external files directory as the ROMs (via USB or adb), then load via:
|
||||
```kotlin
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthias Ladkau
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,180 @@
|
||||
<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, 1989–1992) 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.
|
||||
|
||||

|
||||
|
||||
## 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 the build environment image locally and push it (`./build-image.sh`
|
||||
then `./upload-image.sh`, needs `registry.env` — copy from
|
||||
`registry.env.example`). Use `./run-image.sh` in between to sanity-check
|
||||
the image before pushing.
|
||||
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` |
|
||||
| `REGISTRY_USER` / `REGISTRY_PASSWORD` | same as in `registry.env`, so the runner can pull the private build image |
|
||||
|
||||
`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
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# ROM files extracted from the VICE tarball by res/extract_roms.sh
|
||||
/res/roms
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/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/basic
|
||||
/src/main/assets/chargen
|
||||
/src/main/assets/1541
|
||||
/src/main/assets/1541
|
||||
|
||||
@@ -4,6 +4,11 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
|
||||
val keystoreProps = Properties().apply {
|
||||
rootProject.file("keystore.properties").takeIf { it.exists() }
|
||||
?.reader()?.use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.ladkau.schwertundmagieonpebblecompanionapp"
|
||||
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 {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
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 {
|
||||
if (name.startsWith("configureCMake") || name.startsWith("buildCMake") ||
|
||||
name.startsWith("externalNativeBuild")) {
|
||||
dependsOn("buildVice")
|
||||
dependsOn("buildNibtools")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ROM extraction task
|
||||
//
|
||||
// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android
|
||||
// assets directory so they are packaged into the APK automatically.
|
||||
// Output files are gitignored — the tarball is the single source of truth.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
// Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are
|
||||
// copyrighted Commodore firmware. The app prompts the user to import their
|
||||
// own ROM dump at runtime instead (see MainActivity.showRomImportDialog()).
|
||||
// res/extract_roms.sh remains available for extracting ROMs from the VICE
|
||||
// tarball locally (e.g. to sideload onto a test device), but nothing in this
|
||||
// build copies them into assets/ or the APK.
|
||||
|
||||
dependencies {
|
||||
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. */
|
||||
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). */
|
||||
external fun runFrame()
|
||||
|
||||
@@ -52,6 +58,16 @@ class C64Engine {
|
||||
/** Monotonically increasing frame counter — incremented once per rendered C64 frame. */
|
||||
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 {
|
||||
init { System.loadLibrary("vice_jni") }
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@ import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Choreographer
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.ScrollView
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -26,10 +29,17 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import org.json.JSONObject
|
||||
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.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
private const val PORT = 8888
|
||||
private const val TAG = "SuM"
|
||||
@@ -64,6 +74,24 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// ---- 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(
|
||||
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"),
|
||||
@@ -77,8 +105,9 @@ class MainActivity : AppCompatActivity() {
|
||||
DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"),
|
||||
DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"),
|
||||
)
|
||||
private val episodeButtons = mutableMapOf<DiskSlot, Button>()
|
||||
private val loadedADisks = mutableSetOf<Int>()
|
||||
private val episodeButtons = mutableMapOf<DiskSlot, Button>()
|
||||
private val heroEditButtons = mutableMapOf<DiskSlot, Button>()
|
||||
private val loadedADisks = mutableSetOf<Int>()
|
||||
private var activeSlot: DiskSlot? = null
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
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_anleitung).setOnClickListener { showAnleitung() }
|
||||
findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() }
|
||||
|
||||
findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
|
||||
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)
|
||||
btnSound.setOnClickListener {
|
||||
@@ -187,6 +335,17 @@ class MainActivity : AppCompatActivity() {
|
||||
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()
|
||||
initEmulator()
|
||||
refreshDiskButtons()
|
||||
@@ -200,7 +359,14 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun initEmulator() {
|
||||
val assetDir = getExternalFilesDir(null) ?: filesDir
|
||||
assetDir.mkdirs()
|
||||
copyBundledRoms(assetDir)
|
||||
if (missingRoms(assetDir).isNotEmpty()) {
|
||||
showRomImportDialog(assetDir)
|
||||
return
|
||||
}
|
||||
startEmulator(assetDir)
|
||||
}
|
||||
|
||||
private fun startEmulator(assetDir: File) {
|
||||
val romDir = assetDir.absolutePath
|
||||
val ok = engine.initEmulator(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) {
|
||||
for (name in bundledRoms) {
|
||||
val dest = File(destDir, name)
|
||||
if (dest.exists()) continue
|
||||
try {
|
||||
assets.open(name).use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
private fun missingRoms(dir: File): List<String> =
|
||||
requiredRoms.filter { !File(dir, it).exists() }
|
||||
|
||||
// Identifies which canonical ROM name a picked file corresponds to, based
|
||||
// on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as
|
||||
// well as plain names a user might have renamed a dump to.
|
||||
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) -----------------------------------------
|
||||
|
||||
private fun startHttpServer() {
|
||||
@@ -266,7 +650,9 @@ class MainActivity : AppCompatActivity() {
|
||||
onKey = { cmd ->
|
||||
Log.d(TAG, "Watch key: $cmd")
|
||||
mainHandler.post { appendLog("WATCH: $cmd") }
|
||||
}
|
||||
injectWatchKey(cmd)
|
||||
},
|
||||
getScreenText = { engine.getScreenText() }
|
||||
)
|
||||
try {
|
||||
server?.start()
|
||||
@@ -280,17 +666,25 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun diskFile(slot: DiskSlot): File? {
|
||||
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() {
|
||||
for (slot in diskSlots) {
|
||||
val exists = diskFile(slot) != null
|
||||
val btn = episodeButtons[slot] ?: continue
|
||||
btn.isEnabled = diskFile(slot) != null
|
||||
btn.isEnabled = exists
|
||||
btn.backgroundTintList = if (slot == activeSlot)
|
||||
ColorStateList.valueOf(Color.parseColor("#1A4A1A"))
|
||||
else
|
||||
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. */
|
||||
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)
|
||||
|
||||
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
|
||||
val bam = offset(18, 0)
|
||||
val bam = d64Off(18, 0)
|
||||
data[bam + 0] = 18 // first directory track
|
||||
data[bam + 1] = 1 // first directory sector
|
||||
data[bam + 2] = 0x41 // DOS version 'A'
|
||||
@@ -413,7 +791,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
for (t in 1..35) {
|
||||
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
|
||||
var mask = (1 shl sectors) - 1
|
||||
var free = sectors
|
||||
@@ -440,13 +818,194 @@ class MainActivity : AppCompatActivity() {
|
||||
for (i in 167..255) data[bam + i] = 0xA0.toByte()
|
||||
|
||||
// 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 + 1] = 0xFF.toByte()
|
||||
|
||||
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 ------------------------------------------------------
|
||||
|
||||
private fun dismissSplashDelayed() {
|
||||
@@ -460,6 +1019,112 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// ---- 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() {
|
||||
fun load(german: Boolean) = try {
|
||||
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) {
|
||||
val ts = timeFormat.format(Date())
|
||||
tvLog.text = "[$ts] $entry\n${tvLog.text}"
|
||||
@@ -520,7 +1213,8 @@ class CompanionServer(
|
||||
port: Int,
|
||||
private val timeFormat: SimpleDateFormat,
|
||||
private val onTimeFetched: (String) -> Unit,
|
||||
private val onKey: (String) -> Unit
|
||||
private val onKey: (String) -> Unit,
|
||||
private val getScreenText: () -> String
|
||||
) : NanoHTTPD(port) {
|
||||
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
@@ -537,9 +1231,40 @@ class CompanionServer(
|
||||
if (cmd.isNotEmpty()) onKey(cmd)
|
||||
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")
|
||||
}
|
||||
response.addHeader("Access-Control-Allow-Origin", "*")
|
||||
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)")
|
||||
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 -------------------------------------------------
|
||||
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)
|
||||
|
||||
@@ -30,7 +41,12 @@ if(HAVE_VICE)
|
||||
-Wl,--wrap=machine_init
|
||||
-Wl,--wrap=console_init
|
||||
-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
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}"
|
||||
"${VICE_SRC}/src"
|
||||
@@ -43,3 +59,12 @@ if(HAVE_VICE)
|
||||
)
|
||||
target_link_libraries(vice_jni "${VICE_LIB}")
|
||||
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,101 @@
|
||||
#!/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}"
|
||||
# Hardcoded, not a glob: on a non-ephemeral runner workspace, a
|
||||
# nibtools-*/ glob can also match SRC itself once it exists, or other
|
||||
# stray nibtools-prefixed leftovers, and mv then fails with "target is
|
||||
# not a directory" (multiple sources, no existing target dir).
|
||||
mv "${SCRIPT_DIR}/nibtools-91344e0ee3" "${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 ==="
|
||||
@@ -32,7 +32,7 @@ echo " TOOLCHAIN : ${TOOLCHAIN} [OK]"
|
||||
|
||||
# ---- Ensure required host tools are present ---------------------------------
|
||||
MISSING=()
|
||||
for tool in dos2unix autoconf automake pkg-config xa; do
|
||||
for tool in dos2unix autoconf automake pkg-config xa flex; do
|
||||
command -v "$tool" &>/dev/null || MISSING+=("$tool")
|
||||
done
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
|
||||
@@ -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 int g_ready = 0;
|
||||
|
||||
/* Pending disk/reset operations — written by Android thread, consumed by
|
||||
* video_canvas_refresh on the VICE thread (machine_trigger_reset and disk APIs
|
||||
* must be called from the VICE thread to avoid corrupting internal state).
|
||||
* g_pending_disk → autostart_disk (resets the machine, used for A-side disks)
|
||||
* g_pending_attach → file_system_attach_disk (hot-swap, used for B-side / hero disks)
|
||||
* g_pending_reset → detach disk + hard reset to BASIC prompt */
|
||||
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
/* Pending disk/reset/snapshot operations — written by Android thread, consumed by
|
||||
* video_canvas_refresh on the VICE thread (machine_trigger_reset, disk APIs, and
|
||||
* snapshot APIs must be called from the VICE thread to avoid corrupting state).
|
||||
* g_pending_disk → autostart_disk (resets machine, used for A-side 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_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_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)
|
||||
@@ -67,6 +77,9 @@ extern int __real_console_init(void);
|
||||
#include "keyboard.h"
|
||||
#include "autostart.h"
|
||||
#include "attach.h"
|
||||
#include "interrupt.h"
|
||||
#include "charset.h"
|
||||
#include "mem.h"
|
||||
|
||||
/* Drive LED state.
|
||||
* 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. */
|
||||
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). */
|
||||
|
||||
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_attach[0] != '\0') { strncpy(attach_path, g_pending_attach, sizeof(attach_path)-1); g_pending_attach[0] = '\0'; }
|
||||
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);
|
||||
strncpy(g_current_disk_path, autostart_path, sizeof(g_current_disk_path) - 1);
|
||||
}
|
||||
if (attach_path[0] != '\0') {
|
||||
int r = file_system_attach_disk(8, 0, attach_path);
|
||||
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_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.
|
||||
* VICE's virtual device (vdrive) loads far faster than a real 1541, so
|
||||
* autostart_in_progress() is false long before the GDG intro finishes.
|
||||
@@ -685,7 +807,69 @@ JNI_FN(jboolean, getDriveLed)(JNIEnv *env, jobject obj) {
|
||||
|
||||
JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) {
|
||||
(void)env; (void)obj;
|
||||
#ifdef HAVE_VICE_SRC
|
||||
return (jint)g_frame_count;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -715,5 +899,41 @@ JNI_FN(void, injectKey)(JNIEnv *env, jobject obj, jint keyCode, jboolean pressed
|
||||
|
||||
JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) {
|
||||
(void)env; (void)obj;
|
||||
#ifdef HAVE_VICE_SRC
|
||||
g_sound_enabled = enabled ? 1 : 0;
|
||||
#else
|
||||
(void)enabled;
|
||||
#endif
|
||||
}
|
||||
|
||||
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_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
|
||||
android:id="@+id/btn_help"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
@@ -206,6 +228,10 @@
|
||||
<Button android:id="@+id/btn_hero_1_new" style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
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 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"
|
||||
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
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 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"
|
||||
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
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>
|
||||
|
||||
<View
|
||||
@@ -236,14 +270,38 @@
|
||||
android:background="#333355"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_import_disks"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/btn_import_disks"
|
||||
android:textSize="12sp" />
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<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
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@android:color/transparent"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@android:color/transparent"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -11,12 +11,30 @@
|
||||
|
||||
<!-- Bottom drawer buttons -->
|
||||
<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_on">Ton: AN</string>
|
||||
|
||||
<!-- Disk picker -->
|
||||
<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 -->
|
||||
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
|
||||
|
||||
@@ -29,4 +47,17 @@
|
||||
<!-- Hero disk creation log -->
|
||||
<string name="hero_disk_created">Erstellt: %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>
|
||||
|
||||
@@ -13,12 +13,30 @@
|
||||
|
||||
<!-- Bottom drawer buttons -->
|
||||
<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_on">Sound: ON</string>
|
||||
|
||||
<!-- Disk picker -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<string name="hero_disk_created">Created: %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>
|
||||
|
||||
|
After Width: | Height: | Size: 454 KiB |
@@ -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"
|
||||
@@ -24,10 +24,17 @@
|
||||
},
|
||||
"messageKeys": [
|
||||
"TIME",
|
||||
"COMMAND"
|
||||
"COMMAND",
|
||||
"SCREEN"
|
||||
],
|
||||
"resources": {
|
||||
"media": []
|
||||
"media": [
|
||||
{
|
||||
"type": "bitmap",
|
||||
"name": "IMAGE_SPLASH",
|
||||
"file": "splash.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -1,15 +1,39 @@
|
||||
#include <pebble.h>
|
||||
|
||||
enum {
|
||||
KEY_TIME = 0, // Inbound: current time string from companion app
|
||||
KEY_COMMAND = 1 // Outbound: button name sent to 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_SCREEN = 2 // Inbound: C64 text screen (40x25, ASCII, rows separated by '\n')
|
||||
};
|
||||
|
||||
static Window *s_main_window;
|
||||
static TextLayer *s_time_layer;
|
||||
static TextLayer *s_hint_layer;
|
||||
#define SCREEN_FONT FONT_KEY_GOTHIC_24
|
||||
|
||||
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) {
|
||||
DictionaryIterator *iter;
|
||||
@@ -19,69 +43,183 @@ static void send_key_to_phone(const char *key_str) {
|
||||
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) {
|
||||
Tuple *time_tuple = dict_find(iterator, KEY_TIME);
|
||||
if (time_tuple && time_tuple->type == TUPLE_CSTRING) {
|
||||
snprintf(s_time_buffer, sizeof(s_time_buffer), "%s", time_tuple->value->cstring);
|
||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
||||
layer_mark_dirty(text_layer_get_layer(s_time_layer));
|
||||
Tuple *screen_tuple = dict_find(iterator, KEY_SCREEN);
|
||||
if (screen_tuple && screen_tuple->type == TUPLE_CSTRING) {
|
||||
snprintf(s_screen_buffer, sizeof(s_screen_buffer), "%s", screen_tuple->value->cstring);
|
||||
text_layer_set_text(s_screen_layer, s_screen_buffer);
|
||||
update_screen_layout();
|
||||
layer_mark_dirty(text_layer_get_layer(s_screen_layer));
|
||||
}
|
||||
}
|
||||
|
||||
static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone("UP");
|
||||
static void update_wheel_label(void) {
|
||||
text_layer_set_text(s_wheel_label_layer, k_wheel_items[s_wheel_index]);
|
||||
}
|
||||
|
||||
static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone("DOWN");
|
||||
static void wheel_up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
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) {
|
||||
send_key_to_phone("SELECT");
|
||||
window_stack_push(s_wheel_window, true);
|
||||
}
|
||||
|
||||
static void click_config_provider(void *context) {
|
||||
window_single_click_subscribe(BUTTON_ID_UP, up_click_handler);
|
||||
window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler);
|
||||
// Passed to scroll_layer_set_callbacks(): the ScrollLayer's own click config
|
||||
// provider calls this after wiring UP/DOWN to scroll, so we only need to add
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
s_time_layer = text_layer_create(GRect(0, 55, bounds.size.w, 50));
|
||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
||||
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK));
|
||||
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_time_layer));
|
||||
s_scroll_layer = scroll_layer_create(bounds);
|
||||
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
|
||||
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks) {
|
||||
.click_config_provider = scroll_click_config_provider
|
||||
});
|
||||
|
||||
s_hint_layer = text_layer_create(GRect(0, 130, bounds.size.w, 30));
|
||||
text_layer_set_text(s_hint_layer, "UP / DN / SEL");
|
||||
text_layer_set_font(s_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14));
|
||||
text_layer_set_text_alignment(s_hint_layer, GTextAlignmentCenter);
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_hint_layer));
|
||||
// The 40-column C64 grid is not preserved — the companion app's text
|
||||
// already has '\n' per row, but at this font size lines reflow
|
||||
// (word-wrap) rather than line up like the original screen.
|
||||
s_screen_layer = text_layer_create(GRect(0, 0, bounds.size.w, bounds.size.h));
|
||||
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) {
|
||||
text_layer_destroy(s_time_layer);
|
||||
text_layer_destroy(s_hint_layer);
|
||||
text_layer_destroy(s_screen_layer);
|
||||
scroll_layer_destroy(s_scroll_layer);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
s_main_window = window_create();
|
||||
window_set_click_config_provider(s_main_window, click_config_provider);
|
||||
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
||||
.load = main_window_load,
|
||||
.unload = main_window_unload
|
||||
});
|
||||
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_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) {
|
||||
window_destroy(s_splash_window);
|
||||
window_destroy(s_wheel_window);
|
||||
window_destroy(s_main_window);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ var SERVER = 'http://127.0.0.1:8888';
|
||||
|
||||
var KEY_TIME = 0;
|
||||
var KEY_COMMAND = 1;
|
||||
var KEY_SCREEN = 2;
|
||||
|
||||
function httpGet(url) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -12,15 +13,15 @@ function httpGet(url) {
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function sendTimeToWatch() {
|
||||
function sendScreenToWatch() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', SERVER + '/time', true);
|
||||
xhr.open('GET', SERVER + '/screen', true);
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
var msg = {};
|
||||
msg[KEY_TIME] = data.time;
|
||||
msg[KEY_SCREEN] = data.text;
|
||||
Pebble.sendAppMessage(
|
||||
msg,
|
||||
function() {},
|
||||
@@ -32,13 +33,13 @@ function sendTimeToWatch() {
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
Pebble.addEventListener('ready', function() {
|
||||
setInterval(sendTimeToWatch, 1000);
|
||||
setInterval(sendScreenToWatch, 1000);
|
||||
});
|
||||
|
||||
Pebble.addEventListener('appmessage', function(e) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the release build environment image (build-image/Dockerfile)
|
||||
# locally, tagged with build-image/VERSION and :latest. Does not push —
|
||||
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
|
||||
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_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||
|
||||
VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
|
||||
|
||||
echo "== Building $REGISTRY_IMAGE:$VERSION =="
|
||||
# Context is the repo root (not build-image/) so the Dockerfile's
|
||||
# gradle-cache-warm stage can COPY in the companion app's real Gradle
|
||||
# project files — see .dockerignore for what's excluded from that context.
|
||||
docker build \
|
||||
-t "$REGISTRY_IMAGE:$VERSION" \
|
||||
-t "$REGISTRY_IMAGE:latest" \
|
||||
-f "$ROOT/build-image/Dockerfile" \
|
||||
"$ROOT"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
|
||||
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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 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 AS base
|
||||
|
||||
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 ANDROID_CMAKE=3.22.1
|
||||
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).
|
||||
# openssh-client: the release workflow's `sftp` upload to dl.ladkau.de.
|
||||
# python3-venv: pebble-tool's `sdk install` creates a venv per SDK version.
|
||||
# dos2unix/autoconf/automake/pkg-config/xa65/build-essential/gettext/flex/bison:
|
||||
# host tools required by the companion app's build_vice.sh (see that file's
|
||||
# own preflight check, plus flex/bison for VICE's AC_PROG_LEX/AC_PROG_YACC-
|
||||
# based configure) 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 openssh-client \
|
||||
dos2unix autoconf automake pkg-config xa65 build-essential gettext flex bison \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Android SDK: cmdline-tools, platform, build-tools, NDK, CMake ---
|
||||
# CMake version must match app/build.gradle.kts's externalNativeBuild.cmake.version
|
||||
# — baking it in here avoids AGP installing it on first `docker run` instead.
|
||||
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}" \
|
||||
"cmake;${ANDROID_CMAKE}" \
|
||||
>/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}"
|
||||
|
||||
# --- Warm the Gradle dependency cache ---
|
||||
# The companion app's real Gradle project files (not the generated build/
|
||||
# output, and not the VICE/nibtools source tarballs — see .dockerignore) are
|
||||
# COPYed into a throwaway location and built once here, so every actual
|
||||
# `docker run` of this image (which bind-mounts a fresh checkout over
|
||||
# /workspace) hits a warm ~/.gradle cache instead of re-downloading the same
|
||||
# Maven dependencies from dl.google.com/mavenCentral every single run.
|
||||
#
|
||||
# buildVice/buildNibtools are excluded (their source tarballs aren't in the
|
||||
# build context) — CMake already handles that gracefully, falling back to a
|
||||
# placeholder (see vice_jni.c / build_vice.sh's own header comment) — so this
|
||||
# stage only ever warms the Gradle/Maven dependency cache, never bakes in
|
||||
# compiled VICE/nibtools output.
|
||||
#
|
||||
# packageRelease/signReleaseBundle are also excluded — they're the only tasks
|
||||
# that need the release keystore, which never exists here (secrets are
|
||||
# injected at job runtime, never baked into the image). Excluding them lets
|
||||
# everything upstream (dependency resolution, Kotlin/native compilation,
|
||||
# resource merging, dexing) still run and get cached, without two guaranteed,
|
||||
# noisy "missing storeFile" failures cluttering every image build.
|
||||
#
|
||||
# This is a pure optimization: if the app's dependencies change after this
|
||||
# image was built, Gradle just downloads the delta against the warm cache at
|
||||
# `docker run` time — same as it would without this stage, just slower for
|
||||
# that one run, never broken. `|| true` means a transient network failure
|
||||
# here only costs a slower first `docker run`, never breaks the image build.
|
||||
FROM base AS gradle-cache-warm
|
||||
COPY SchwertUndMagieOnPebbleCompanionApp /tmp/warm/SchwertUndMagieOnPebbleCompanionApp
|
||||
WORKDIR /tmp/warm/SchwertUndMagieOnPebbleCompanionApp
|
||||
RUN chmod +x gradlew \
|
||||
&& (./gradlew bundleRelease assembleRelease \
|
||||
-x buildVice -x buildNibtools \
|
||||
-x packageRelease -x signReleaseBundle \
|
||||
--continue || true)
|
||||
|
||||
FROM base
|
||||
COPY --from=gradle-cache-warm /root/.gradle /root/.gradle
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1 @@
|
||||
4
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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]} ))
|
||||
# Android requires a positive versionCode — the untagged dev placeholder
|
||||
# (0.0.0-dev+<sha>) would otherwise compute to 0 and fail Gradle configuration.
|
||||
[ "$VERSION_CODE" -gt 0 ] || VERSION_CODE=1
|
||||
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"
|
||||
# Pebble's own build tooling parses package.json's version strictly as
|
||||
# X.Y.Z integers — it rejects the -pre+meta suffix dist.sh otherwise allows
|
||||
# (including the default "0.0.0-dev+<sha>" placeholder), so strip it here.
|
||||
PEBBLE_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
||||
sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$PEBBLE_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"
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||

|
||||
|
||||
- **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)
|
||||
|
||||

|
||||
|
||||
`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)
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||

|
||||
|
||||
### 4.4 Snapshot save/load (CPU-trap register sync)
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 178 KiB |
@@ -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)
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 114 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 101 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -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)
|
||||
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,432 @@
|
||||
# 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 locally and pushed to a container registry by
|
||||
three separate scripts (below). 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.
|
||||
|
||||
Three scripts, kept separate so a Dockerfile change can be built and tested
|
||||
locally before anything is pushed to the registry:
|
||||
|
||||
```bash
|
||||
cp registry.env.example registry.env # fill in your registry credentials
|
||||
./build-image.sh # builds :latest and the pinned VERSION tag, locally only
|
||||
./run-image.sh # runs dist.sh inside that local image — sanity-check before publishing
|
||||
./upload-image.sh # pushes the already-built :latest and VERSION tag to cr.ladkau.de
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`run-image.sh` bind-mounts this repo straight into the container, so before
|
||||
each run it wipes generated build artifacts (`vice-src`, `vice-libs`,
|
||||
`nibtools-src`, `nibtools-libs`, `app/build`, `app/.cxx`, the watch app's
|
||||
`build/`) — otherwise leftovers from a previous local run would let
|
||||
`build_vice.sh`/`build_nibtools.sh` skip work a real fresh CI checkout always
|
||||
does, hiding bugs that only show up in CI. The container also runs as root
|
||||
(needed for the baked-in SDK/NDK/Gradle setup), so it chowns the whole repo
|
||||
back to your host user on exit — you shouldn't ever need `sudo` to clean up
|
||||
after it.
|
||||
|
||||
### 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 ubuntu-latest: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 (`ubuntu-latest` above) must match `runs-on:`
|
||||
in `.gitea/workflows/release.yml` — edit both together if you rename it,
|
||||
or reuse a label an existing runner already advertises (check Site Admin →
|
||||
Actions → Runners) to skip registering a new one entirely.
|
||||
|
||||
No manual `docker login` needed on the runner host — the workflow's
|
||||
`container:` block authenticates the image pull itself via the
|
||||
`REGISTRY_USER`/`REGISTRY_PASSWORD` secrets (§4.3).
|
||||
|
||||
### 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) |
|
||||
| `REGISTRY_USER` | same as `REGISTRY_USER` in `registry.env` |
|
||||
| `REGISTRY_PASSWORD` | same as `REGISTRY_PASSWORD` in `registry.env` |
|
||||
| `DL_SFTP_KEY` | private key (PEM) for the `uploader` SFTP account on dl.ladkau.de |
|
||||
|
||||
`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,
|
||||
and the same three files are uploaded over SFTP to
|
||||
`dl.ladkau.de:files/schwert-und-magie/` (using the `DL_SFTP_KEY` secret,
|
||||
§4.3) for direct download outside of Gitea.
|
||||
|
||||
To build the same versioned artifacts locally without pushing a tag (e.g. to
|
||||
test before releasing), either run `dist.sh` directly with the host toolchain:
|
||||
|
||||
```bash
|
||||
VERSION=1.2.3 ./dist.sh
|
||||
```
|
||||
|
||||
or run it inside the build-image container (same environment the runner
|
||||
uses — see §4.1):
|
||||
|
||||
```bash
|
||||
./run-image.sh 1.2.3
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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=
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs dist.sh inside the local build-image container, mirroring what the
|
||||
# Gitea Actions release workflow does — useful for testing build-image
|
||||
# changes (or dist.sh/build_vice.sh/build_nibtools.sh changes) locally
|
||||
# before pushing anything to cr.ladkau.de.
|
||||
#
|
||||
# Usage: ./run-image.sh [VERSION]
|
||||
# VERSION is passed through to dist.sh; omit it for dist.sh's own
|
||||
# git-tag-based default (see dist.sh's header comment).
|
||||
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_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||
|
||||
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
|
||||
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
|
||||
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||
|| fail "$IMAGE not found locally — run ./build-image.sh first"
|
||||
|
||||
VERSION="${1:-${VERSION:-}}"
|
||||
|
||||
# CI always starts from a fresh checkout, but this script bind-mounts the live
|
||||
# host repo — so build outputs left over from a previous local run (e.g. a
|
||||
# vice-src/ already configured, or a libvice.a that's already built) would
|
||||
# make build_vice.sh/build_nibtools.sh skip work they'd have to do on a real
|
||||
# fresh checkout, silently hiding bugs (like a missing host build tool) that
|
||||
# only show up in CI. Wipe them first so every run exercises a true from-
|
||||
# scratch build, same as CI.
|
||||
JNI="$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni"
|
||||
echo "== Cleaning generated build artifacts for a fresh build =="
|
||||
rm -rf \
|
||||
"$JNI/vice-src" "$JNI/vice-libs" \
|
||||
"$JNI/nibtools-src" "$JNI/nibtools-libs" \
|
||||
"$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/build" \
|
||||
"$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/.cxx" \
|
||||
"$ROOT/SchwertUndMagieOnPebbleWatchApp/build"
|
||||
|
||||
echo "== Running dist.sh inside $IMAGE =="
|
||||
# The container runs as root (needed for the SDK/NDK/Gradle setup baked into
|
||||
# the image), so anything it writes into this bind mount — dist/, app/build,
|
||||
# .cxx, etc. — would otherwise come back owned by root, leaving the host repo
|
||||
# unusable without sudo. Chown everything back to the host user on exit,
|
||||
# whether dist.sh succeeds or fails.
|
||||
docker run --rm \
|
||||
-v "$ROOT:/workspace" \
|
||||
-w /workspace \
|
||||
-e VERSION="$VERSION" \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
"$IMAGE" \
|
||||
bash -c '
|
||||
git config --global --add safe.directory /workspace
|
||||
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
|
||||
./dist.sh
|
||||
'
|
||||
|
||||
echo "== Done — artifacts in dist/ =="
|
||||
ls -la "$ROOT/dist"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes the build environment image — already built locally with
|
||||
# ./build-image.sh, and ideally verified with ./run-image.sh — to
|
||||
# cr.ladkau.de. The Gitea Actions release workflow pulls this image to
|
||||
# run dist.sh.
|
||||
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"
|
||||
|
||||
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
|
||||
|
||||
echo "== Logging in to $REGISTRY =="
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
|
||||
docker push "$REGISTRY_IMAGE:$VERSION"
|
||||
docker push "$REGISTRY_IMAGE:latest"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $REGISTRY_IMAGE:$VERSION"
|
||||