Add Fetch Disks button and Anleitung copy-protection viewer

- Cross-compile nibtools for Android NDK; download all 8 episode disks
  from the Internet Archive C64 Preservation Project as NBZ and convert
  to G64 via JNI (throwaway-pthread to contain nibconv's exit() calls)
- Bundle manual transcription as APK asset; 📖 button opens scrollable
  viewer with Seite→Absatz→Zeile→Wort lookup hierarchy documented
This commit is contained in:
ml
2026-06-27 10:36:15 +02:00
parent aaa6e772a7
commit daefed88b1
16 changed files with 765 additions and 10 deletions
+2
View File
@@ -20,6 +20,8 @@ local.properties
/SchwertUndMagieOnPebbleCompanionApp/app/.cxx /SchwertUndMagieOnPebbleCompanionApp/app/.cxx
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src /SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs /SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-src
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-libs
/SchwertUndMagieOnPebbleWatchApp/build /SchwertUndMagieOnPebbleWatchApp/build
+33 -1
View File
@@ -118,7 +118,8 @@ during development, but nothing in the Gradle build copies them into the APK.
| `C64DisplayView.kt` | SurfaceView — blits 320×200 ARGB framebuffer, scaled with correct aspect ratio | | `C64DisplayView.kt` | SurfaceView — blits 320×200 ARGB framebuffer, scaled with correct aspect ratio |
| `C64KeyboardView.kt` | Multi-touch virtual C64 keyboard; fires `KeyEventListener` on press/release | | `C64KeyboardView.kt` | Multi-touch virtual C64 keyboard; fires `KeyEventListener` on press/release |
| `jni/vice_jni.c` | JNI wrapper — custom VICE video canvas writes frames to `g_framebuf[320×200]` | | `jni/vice_jni.c` | JNI wrapper — custom VICE video canvas writes frames to `g_framebuf[320×200]` |
| `jni/CMakeLists.txt` | NDK build; conditionally links `libvice.a` when `HAVE_VICE_SRC=1` | | `jni/nibconv_jni.c` | JNI wrapper around nibtools' nibconv — converts downloaded NIB/NBZ dumps to G64 |
| `jni/CMakeLists.txt` | NDK build; conditionally links `libvice.a`/`libnibtools.a` when their `HAVE_*` flags are set |
### Disk images ### Disk images
@@ -127,3 +128,34 @@ same external files directory as the ROMs (via USB or adb), then load via:
```kotlin ```kotlin
engine.loadDisk(getExternalFilesDir(null)!!.absolutePath + "/schwert_und_magie_1.d64") engine.loadDisk(getExternalFilesDir(null)!!.absolutePath + "/schwert_und_magie_1.d64")
``` ```
Alternatively, the in-app **Download Disks** button (next to Import Disks) fetches all
8 episode disks from the Internet Archive's C64 Preservation Project and converts them
to G64 automatically — see "Disk download (nibtools)" below.
### Disk download (nibtools)
The Internet Archive's C64 Preservation Project only has these disks as `.nbz`
(nibtools' compressed raw-GCR NIB format) — not `.d64`. `MainActivity.downloadDisks()`
fetches each of the 8 `.nbz` files (one per disk side) and runs them through nibtools'
`nibconv` to produce G64 images (VICE attaches G64 exactly like D64, detecting the
format from file content, not the extension). G64 was chosen over the lossy D64
reconstruction nibconv also supports, since these old dumps aren't always cleanly
sector-readable — G64 preserves whatever the original drive actually saw.
Only `nibconv`'s pure file-format conversion code is built (`gcr.c prot.c fileio.c
crc.c md5.c lz.c nibconv.c`) — nibtools' hardware-access tools (`nibread`/`nibwrite`,
which talk to a real 1541 over OpenCBM) are not needed and not built. `jni/nibtools_android/`
stubs out the OpenCBM header so the unused declarations that pull it in still compile.
**nibtools compilation is automatic**, the same way as VICE:
1. The Gradle `buildNibtools` task runs before every native CMake build
2. It calls `app/src/main/jni/build_nibtools.sh`, which unpacks `res/nibtools-<rev>.tar.gz`
and cross-compiles the files above into `nibtools-libs/<abi>/libnibtools.a`
3. `CMakeLists.txt` auto-detects the library via `EXISTS` and links it in
nibconv's `main()` is renamed to `nibtools_nibconv_main` at compile time (`-Dmain=...`)
so it can coexist in the same shared library as the JNI entry points. It's invoked
from `nibconv_jni.c` on a throwaway pthread — nibconv calls `exit()` on malformed input,
which is wrapped (`-Wl,--wrap=exit`) to `pthread_exit()` so a bad conversion only kills
that disposable thread, never the app process or the calling JNI thread.
@@ -122,11 +122,77 @@ tasks.register("buildVice") {
} }
} }
// Run buildVice before any CMake configure or build step. // ---------------------------------------------------------------------------
// nibtools cross-compilation task
//
// Builds nibconv (NIB/NBZ -> G64/D64 disk image conversion, used by the
// "Download Disks" feature) the same way buildVice builds VICE above.
// Skipped entirely if nibtools-libs/<abi>/libnibtools.a already exists.
// The tarball at <project>/res/nibtools-91344e0ee3.tar.gz is unpacked by
// build_nibtools.sh.
// ---------------------------------------------------------------------------
val nibtoolsLibArm = layout.projectDirectory.file("src/main/jni/nibtools-libs/arm64-v8a/libnibtools.a")
val nibtoolsLibX86 = layout.projectDirectory.file("src/main/jni/nibtools-libs/x86_64/libnibtools.a")
val nibtoolsTarball = layout.projectDirectory.file("../res/nibtools-91344e0ee3.tar.gz")
tasks.register("buildNibtools") {
group = "build"
description = "Cross-compile nibtools' nibconv for Android (ARM64 + x86_64)"
inputs.file(nibtoolsTarball)
outputs.file(nibtoolsLibArm)
outputs.file(nibtoolsLibX86)
doLast {
if (nibtoolsLibArm.asFile.exists() && nibtoolsLibX86.asFile.exists()) {
logger.lifecycle("nibtools already built — skipping")
return@doLast
}
val localProps = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }
?.reader()?.use { load(it) }
}
val sdkDir = localProps.getProperty("sdk.dir")
?: System.getenv("ANDROID_HOME")
?: ""
val ndkPath = localProps.getProperty("ndk.dir")
?: System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: System.getenv("NDK")
?: sdkDir.takeIf { it.isNotEmpty() }?.let { sdk ->
file("$sdk/ndk").takeIf { it.isDirectory }
?.listFiles()?.sorted()?.lastOrNull()?.absolutePath
?: "$sdk/ndk-bundle".takeIf { file("$sdk/ndk-bundle").isDirectory }
}
?: throw GradleException(
"Android NDK not found.\n" +
"Install via: Android Studio → SDK Manager → SDK Tools → NDK (Side by side)"
)
logger.lifecycle("Building nibtools with NDK at $ndkPath")
val proc = ProcessBuilder("bash", "build_nibtools.sh")
.directory(layout.projectDirectory.dir("src/main/jni").asFile)
.redirectErrorStream(true)
.also { it.environment()["NDK"] = ndkPath }
.start()
proc.inputStream.bufferedReader().forEachLine { logger.lifecycle(it) }
val exit = proc.waitFor()
if (exit != 0) throw GradleException("build_nibtools.sh failed (exit $exit)")
layout.projectDirectory.file("src/main/jni/CMakeLists.txt")
.asFile.setLastModified(System.currentTimeMillis())
logger.lifecycle("nibtools build complete — CMakeLists.txt touched for re-evaluation")
}
}
// Run buildVice and buildNibtools before any CMake configure or build step.
tasks.whenTaskAdded { tasks.whenTaskAdded {
if (name.startsWith("configureCMake") || name.startsWith("buildCMake") || if (name.startsWith("configureCMake") || name.startsWith("buildCMake") ||
name.startsWith("externalNativeBuild")) { name.startsWith("externalNativeBuild")) {
dependsOn("buildVice") dependsOn("buildVice")
dependsOn("buildNibtools")
} }
} }
@@ -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!
@@ -61,6 +61,13 @@ class C64Engine {
/** Current 40x25 C64 text screen as ASCII, rows separated by '\n'. */ /** Current 40x25 C64 text screen as ASCII, rows separated by '\n'. */
external fun getScreenText(): String external fun getScreenText(): String
/**
* Converts a disk image file at [inPath] to the format implied by [outPath]'s
* extension (e.g. .nbz/.nib -> .g64/.d64) via nibtools' nibconv, run in-process.
* Returns false (without writing [outPath]) if nibtools wasn't compiled in.
*/
external fun convertDiskImage(inPath: String, outPath: String): Boolean
companion object { companion object {
init { System.loadLibrary("vice_jni") } init { System.loadLibrary("vice_jni") }
@@ -133,6 +133,120 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---- Disk download (Internet Archive C64 Preservation Project) ----------
// Each entry is one side of one physical disk, dumped as a raw-GCR NIB by
// the preservation project (these old disks aren't always cleanly
// sector-readable, so the source format preserves whatever the original
// drive actually saw rather than reconstructing a — possibly wrong — D64).
// nibtools' nibconv (see jni/build_nibtools.sh) converts each to G64,
// which VICE attaches exactly like a D64 (engine.loadDisk/attachDisk
// detect the format from file content, not the extension).
private data class DiskDownload(val baseName: String, val url: String)
private val diskDownloads = listOf(
DiskDownload("SCHWUM1A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s1%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM1B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s2%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM2A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s1%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM2B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s2%5Bgdg_1989%5D%28german%29.nbz"),
DiskDownload("SCHWUM3A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s1%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM3B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s2%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM4A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s1%5Bgdg_1991%5D%28german%29.nbz"),
DiskDownload("SCHWUM4B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s2%5Bgdg_1991%5D%28german%29.nbz"),
)
private fun showDownloadDisksDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.btn_download_disks)
.setMessage(R.string.disks_download_msg)
.setPositiveButton(R.string.disks_download_confirm) { _, _ -> downloadDisks() }
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun downloadDisks() {
val assetDir = getExternalFilesDir(null) ?: filesDir
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val progressBar = android.widget.ProgressBar(
this, null, android.R.attr.progressBarStyleHorizontal
)
val statusTv = TextView(this).apply {
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
setPadding(0, dp(8), 0, 0)
}
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
addView(progressBar)
addView(statusTv)
}
val progressDialog = AlertDialog.Builder(this)
.setTitle(R.string.btn_download_disks)
.setView(content)
.setCancelable(false)
.show()
Thread {
var successCount = 0
for ((index, disk) in diskDownloads.withIndex()) {
mainHandler.post {
progressBar.isIndeterminate = false
progressBar.progress = 0
statusTv.text = getString(R.string.disks_downloading, index + 1, diskDownloads.size)
}
val tmpFile = File(cacheDir, "${disk.baseName}.nbz")
try {
val conn = (URL(disk.url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 30000
instanceFollowRedirects = true
}
try {
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${conn.responseCode}")
}
val total = conn.contentLengthLong
var lastPercent = -1
val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
if (total > 0) {
val percent = ((downloaded * 100) / total).toInt()
if (percent != lastPercent) {
lastPercent = percent
mainHandler.post { progressBar.progress = percent }
}
}
}
tmpFile.outputStream().use { out -> countingStream.copyTo(out) }
} finally {
conn.disconnect()
}
val finalFile = File(assetDir, "${disk.baseName}.G64")
if (engine.convertDiskImage(tmpFile.absolutePath, finalFile.absolutePath)) {
successCount++
mainHandler.post { appendLog("Downloaded: ${disk.baseName}") }
} else {
mainHandler.post { appendLog("Conversion failed: ${disk.baseName}") }
}
} catch (e: Exception) {
Log.e(TAG, "Disk download failed: ${disk.baseName}", e)
mainHandler.post { appendLog("Download failed: ${disk.baseName} (${e.message})") }
} finally {
tmpFile.delete()
}
}
mainHandler.post {
progressDialog.dismiss()
refreshDiskButtons()
val msg = getString(R.string.disks_download_done, successCount, diskDownloads.size)
appendLog(msg)
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
}
}.start()
}
// ---- lifecycle ---------------------------------------------------------- // ---- lifecycle ----------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -177,11 +291,15 @@ class MainActivity : AppCompatActivity() {
} }
findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() } findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() }
findViewById<Button>(R.id.btn_anleitung).setOnClickListener { showAnleitung() }
findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() } findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() }
findViewById<Button>(R.id.btn_import_disks).setOnClickListener { findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true) launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true)
} }
findViewById<Button>(R.id.btn_download_disks).setOnClickListener {
showDownloadDisksDialog()
}
val btnSound = findViewById<Button>(R.id.btn_sound) val btnSound = findViewById<Button>(R.id.btn_sound)
btnSound.setOnClickListener { btnSound.setOnClickListener {
@@ -548,7 +666,13 @@ class MainActivity : AppCompatActivity() {
private fun diskFile(slot: DiskSlot): File? { private fun diskFile(slot: DiskSlot): File? {
val assetDir = getExternalFilesDir(null) ?: filesDir val assetDir = getExternalFilesDir(null) ?: filesDir
return assetDir.listFiles()?.firstOrNull { it.name.uppercase() == slot.fileName } // Episode disks may exist either as an imported/created D64 or as a
// G64 produced by downloadDisks() (nibconv output) — same base name.
val g64Name = slot.fileName.removeSuffix(".D64") + ".G64"
return assetDir.listFiles()?.firstOrNull {
val name = it.name.uppercase()
name == slot.fileName || name == g64Name
}
} }
private fun refreshDiskButtons() { private fun refreshDiskButtons() {
@@ -1035,6 +1159,34 @@ class MainActivity : AppCompatActivity() {
} }
} }
// Transcript of res/schwert-und-magie-anleitung.pdf, used to answer the
// game's copy-protection prompt ("Nenne Wort X in Zeile Y von Absatz Z").
// Paragraph/line breaks are preserved exactly as printed — see the note
// at the top of the asset file for the counting convention.
private fun showAnleitung() {
val text = try {
assets.open("anleitung.txt").bufferedReader().readText()
} catch (e: Exception) { "Anleitung nicht verfügbar." }
val tv = TextView(this).apply {
this.text = text
typeface = android.graphics.Typeface.MONOSPACE
textSize = 12f
setTextColor(Color.parseColor("#DDDDDD"))
val p = (16 * resources.displayMetrics.density).toInt()
setPadding(p, p, p, p)
}
val scroll = ScrollView(this).apply {
addView(tv)
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
AlertDialog.Builder(this)
.setTitle("Anleitung")
.setView(scroll)
.setPositiveButton("OK", null)
.show()
}
private fun appendLog(entry: String) { private fun appendLog(entry: String) {
val ts = timeFormat.format(Date()) val ts = timeFormat.format(Date())
tvLog.text = "[$ts] $entry\n${tvLog.text}" tvLog.text = "[$ts] $entry\n${tvLog.text}"
@@ -14,8 +14,19 @@ else()
message(STATUS "No libvice.a found — building stub (placeholder framebuffer)") message(STATUS "No libvice.a found — building stub (placeholder framebuffer)")
endif() endif()
# Auto-detect whether libnibtools.a was produced by the Gradle buildNibtools task.
set(NIBTOOLS_LIB "${CMAKE_CURRENT_SOURCE_DIR}/nibtools-libs/${ANDROID_ABI}/libnibtools.a")
if(EXISTS "${NIBTOOLS_LIB}")
set(HAVE_NIBTOOLS ON)
message(STATUS "Found ${NIBTOOLS_LIB} — building with disk image download/conversion")
else()
set(HAVE_NIBTOOLS OFF)
message(STATUS "No libnibtools.a found — Download Disks feature will be unavailable")
endif()
# ---- JNI wrapper library ------------------------------------------------- # ---- JNI wrapper library -------------------------------------------------
add_library(vice_jni SHARED vice_jni.c) add_library(vice_jni SHARED vice_jni.c nibconv_jni.c)
target_link_libraries(vice_jni android log c++_shared z OpenSLES) target_link_libraries(vice_jni android log c++_shared z OpenSLES)
@@ -48,3 +59,12 @@ if(HAVE_VICE)
) )
target_link_libraries(vice_jni "${VICE_LIB}") target_link_libraries(vice_jni "${VICE_LIB}")
endif() endif()
if(HAVE_NIBTOOLS)
target_compile_definitions(vice_jni PRIVATE HAVE_NIBTOOLS=1)
# Redirect nibconv's exit() calls (malformed input) to __wrap_exit in
# nibconv_jni.c, which calls pthread_exit() on the throwaway conversion
# thread instead of killing the process.
target_link_options(vice_jni PRIVATE -Wl,--wrap=exit)
target_link_libraries(vice_jni "${NIBTOOLS_LIB}")
endif()
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Cross-compile nibtools' nibconv (NIB/NBZ -> G64/D64 disk image converter)
# as a static library for Android.
# Called automatically by Gradle; can also be run manually:
# export NDK=/path/to/android-ndk && bash build_nibtools.sh
#
# Output: nibtools-libs/arm64-v8a/libnibtools.a
# nibtools-libs/x86_64/libnibtools.a
#
# Only nibconv's pure file-format conversion code is built — gcr.c, prot.c,
# fileio.c, crc.c, md5.c, lz.c and nibconv.c itself. The hardware-access
# parts of nibtools (nibread/nibwrite, talking to a real 1541 over OpenCBM)
# are not built and not needed; see nibtools_android/opencbm.h for why that
# dependency can be stubbed out instead of vendored.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TARBALL="${SCRIPT_DIR}/../../../../res/nibtools-91344e0ee3.tar.gz"
SRC="${SCRIPT_DIR}/nibtools-src"
ANDROID_SHIM="${SCRIPT_DIR}/nibtools_android"
: "${NDK:?NDK env var must point to the Android NDK root}"
echo "=== build_nibtools.sh ==="
echo " SCRIPT_DIR : ${SCRIPT_DIR}"
echo " TARBALL : ${TARBALL}"
echo " SRC : ${SRC}"
echo " NDK : ${NDK}"
TOOLCHAIN="${NDK}/toolchains/llvm/prebuilt/linux-x86_64"
if [ ! -d "${TOOLCHAIN}" ]; then
echo "ERROR: NDK toolchain not found at ${TOOLCHAIN}" >&2
exit 1
fi
if [ ! -f "${TARBALL}" ]; then
echo "ERROR: nibtools tarball not found at ${TARBALL}" >&2
exit 1
fi
if [ ! -d "${SRC}" ]; then
echo "Unpacking nibtools..."
tar -xzf "${TARBALL}" -C "${SCRIPT_DIR}"
mv "${SCRIPT_DIR}"/nibtools-*/ "${SRC}"
echo "Unpacked to ${SRC}"
fi
# Pure conversion sources — no hardware/OpenCBM access (see header comment above).
SOURCES="gcr.c prot.c fileio.c crc.c md5.c lz.c nibconv.c"
build_abi() {
local ABI="$1"
local HOST="$2"
local API=24
local CC="${TOOLCHAIN}/bin/${HOST}${API}-clang"
local AR="${TOOLCHAIN}/bin/llvm-ar"
local OUT="${SCRIPT_DIR}/nibtools-libs/${ABI}"
local BUILD_DIR="/tmp/nibtools-android-${ABI}"
echo ""
echo "--- ABI: ${ABI} ---"
if [ -f "${OUT}/libnibtools.a" ]; then
echo " libnibtools.a already exists — skipping"
return 0
fi
if [ ! -f "${CC}" ]; then
echo "ERROR: Clang not found at ${CC}" >&2
exit 1
fi
mkdir -p "${BUILD_DIR}" "${OUT}"
for src in ${SOURCES}; do
local obj="${BUILD_DIR}/$(basename "${src}" .c).o"
local extra_defs=""
# nibconv.c's main() is renamed at compile time so it can be linked
# into a shared library (which already has its own JNI entry points)
# without clashing with libc's startup expectations for `main`.
if [ "${src}" = "nibconv.c" ]; then
extra_defs="-Dmain=nibtools_nibconv_main"
fi
"${CC}" -std=c99 -O2 -fPIC ${extra_defs} \
-I "${ANDROID_SHIM}" -I "${SRC}" \
-c "${SRC}/${src}" -o "${obj}"
done
"${AR}" -crs "${OUT}/libnibtools.a" "${BUILD_DIR}"/*.o
echo " Built ${OUT}/libnibtools.a"
}
build_abi "arm64-v8a" "aarch64-linux-android"
build_abi "x86_64" "x86_64-linux-android"
echo ""
echo "=== nibtools build complete ==="
@@ -0,0 +1,76 @@
/*
* JNI bridge to nibtools' nibconv — converts a downloaded NIB/NBZ disk dump
* (raw GCR preservation format) into a G64/D64 image VICE can attach.
*
* Without nibtools source (stub mode, HAVE_NIBTOOLS unset):
* convertDiskImage() always returns false. The "Download Disks" feature
* is unavailable but the rest of the app builds and runs normally.
*
* With nibtools source (HAVE_NIBTOOLS=1, set automatically by CMakeLists.txt
* when nibtools-libs/<abi>/libnibtools.a exists):
* nibtools_nibconv_main() — nibconv's own main(), renamed at compile time
* (see build_nibtools.sh) — runs the real conversion. It is run on a
* throwaway pthread rather than the calling JNI thread: nibconv calls
* exit() on malformed input (e.g. a truncated download), and exit() is
* wrapped below to pthread_exit() instead of killing the whole app
* process. Isolating that to a disposable, JNI-unattached thread means a
* bad conversion just fails this one call instead of detaching the
* caller's JNI thread from underneath it.
*/
#include <jni.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#ifdef HAVE_NIBTOOLS
extern int nibtools_nibconv_main(int argc, char **argv);
struct conv_args {
char *in_path;
char *out_path;
int result;
};
static void *run_nibconv(void *arg) {
struct conv_args *a = (struct conv_args *)arg;
char *argv[3] = { "nibconv", a->in_path, a->out_path };
a->result = nibtools_nibconv_main(3, argv);
return NULL;
}
/* Redirects nibconv's error-path exit() calls so they only terminate the
* throwaway conversion thread above, never the whole process. */
void __wrap_exit(int status) {
pthread_exit((void *)(long)status);
}
#endif /* HAVE_NIBTOOLS */
JNIEXPORT jboolean JNICALL
Java_de_ladkau_schwertundmagieonpebblecompanionapp_C64Engine_convertDiskImage(
JNIEnv *env, jobject thiz, jstring inPath, jstring outPath)
{
(void)thiz;
#ifdef HAVE_NIBTOOLS
const char *inC = (*env)->GetStringUTFChars(env, inPath, NULL);
const char *outC = (*env)->GetStringUTFChars(env, outPath, NULL);
struct conv_args args = { strdup(inC), strdup(outC), -1 };
(*env)->ReleaseStringUTFChars(env, inPath, inC);
(*env)->ReleaseStringUTFChars(env, outPath, outC);
pthread_t t;
pthread_create(&t, NULL, run_nibconv, &args);
pthread_join(t, NULL);
free(args.in_path);
free(args.out_path);
return args.result == 0 ? JNI_TRUE : JNI_FALSE;
#else
(void)env; (void)inPath; (void)outPath;
return JNI_FALSE;
#endif
}
@@ -0,0 +1,18 @@
/*
* Android replacement for nibtools' include/LINUX/mnibarch.h — same content,
* minus the <opencbm.h> include (see opencbm.h stub in this directory).
*/
#ifndef NIBTOOLS_ANDROID_MNIBARCH_H
#define NIBTOOLS_ANDROID_MNIBARCH_H
#include <unistd.h>
#define delay(x) usleep((x) * 1000)
#define msleep(x) delay(x)
#define ARCH_MAINDECL
#define ARCH_SIGNALDECL
typedef unsigned char BYTE;
#endif
@@ -0,0 +1,13 @@
/*
* Stub replacing nibtools' dependency on libopencbm (the real 1541 hardware
* driver). nibconv — the only nibtools program we build — never calls any
* OpenCBM function; CBM_FILE only appears in unused declarations pulled in
* transitively via nibtools.h/ihs.h, so a bare type is enough to satisfy
* the compiler without linking the real library.
*/
#ifndef NIBTOOLS_ANDROID_OPENCBM_STUB_H
#define NIBTOOLS_ANDROID_OPENCBM_STUB_H
typedef int CBM_FILE;
#endif
@@ -62,6 +62,17 @@
android:text="💾" android:text="💾"
android:textSize="16sp" /> android:textSize="16sp" />
<Button
android:id="@+id/btn_anleitung"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:minWidth="0dp"
android:padding="0dp"
android:text="📖"
android:textSize="16sp" />
<Button <Button
android:id="@+id/btn_help" android:id="@+id/btn_help"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
@@ -259,14 +270,38 @@
android:background="#333355" android:background="#333355"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp">
<Button <Button
android:id="@+id/btn_import_disks" android:id="@+id/btn_import_disks"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="@string/btn_import_disks" android:text="@string/btn_import_disks"
android:textSize="12sp" /> android:textAllCaps="false"
android:textSize="11sp"
android:singleLine="true"
android:ellipsize="end" />
<Button
android:id="@+id/btn_download_disks"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/btn_download_disks"
android:textAllCaps="false"
android:textSize="11sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -11,12 +11,19 @@
<!-- Bottom drawer buttons --> <!-- Bottom drawer buttons -->
<string name="btn_import_disks">Disketten importieren</string> <string name="btn_import_disks">Disketten importieren</string>
<string name="btn_download_disks">Disketten herunterladen</string>
<string name="sound_off">Ton: AUS</string> <string name="sound_off">Ton: AUS</string>
<string name="sound_on">Ton: AN</string> <string name="sound_on">Ton: AN</string>
<!-- Disk picker --> <!-- Disk picker -->
<string name="picker_title">SCHWUM-Disketten auswählen</string> <string name="picker_title">SCHWUM-Disketten auswählen</string>
<!-- Disk download (Internet Archive C64 Preservation Project) -->
<string name="disks_download_msg">Lädt alle 8 Folgen-Disketten vom C64 Preservation Project des Internet Archive (von der Community erhaltene historische Disketten-Dumps) herunter und konvertiert sie zur Nutzung hier.</string>
<string name="disks_download_confirm">Herunterladen</string>
<string name="disks_downloading">Lade Diskette %1$d von %2$d…</string>
<string name="disks_download_done">%1$d von %2$d Disketten heruntergeladen</string>
<!-- ROM import --> <!-- ROM import -->
<string name="roms_missing_title">C64-ROMs erforderlich</string> <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_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>
@@ -13,12 +13,19 @@
<!-- Bottom drawer buttons --> <!-- Bottom drawer buttons -->
<string name="btn_import_disks">Import Disks</string> <string name="btn_import_disks">Import Disks</string>
<string name="btn_download_disks">Fetch Disks</string>
<string name="sound_off">Sound: OFF</string> <string name="sound_off">Sound: OFF</string>
<string name="sound_on">Sound: ON</string> <string name="sound_on">Sound: ON</string>
<!-- Disk picker --> <!-- Disk picker -->
<string name="picker_title">Select SCHWUM disk images</string> <string name="picker_title">Select SCHWUM disk images</string>
<!-- Disk download (Internet Archive C64 Preservation Project) -->
<string name="disks_download_msg">Downloads all 8 episode disks from the Internet Archive\'s C64 Preservation Project (community-preserved historical disk dumps) and converts them for use here.</string>
<string name="disks_download_confirm">Download</string>
<string name="disks_downloading">Downloading disk %1$d of %2$d…</string>
<string name="disks_download_done">Downloaded %1$d of %2$d disks</string>
<!-- ROM import --> <!-- ROM import -->
<string name="roms_missing_title">C64 ROMs required</string> <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_missing_msg">This app does not include Commodore ROM files (they\'re copyrighted). Please supply your own legally-obtained dump. Missing: %1$s</string>