From daefed88b15f77b953000a213bce422b055e600f Mon Sep 17 00:00:00 2001 From: ml Date: Sat, 27 Jun 2026 10:36:15 +0200 Subject: [PATCH] Add Fetch Disks button and Anleitung copy-protection viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + CLAUDE.md | 34 ++- .../app/build.gradle.kts | 68 +++++- .../app/src/main/assets/anleitung.txt | 223 ++++++++++++++++++ .../C64Engine.kt | 7 + .../MainActivity.kt | 154 +++++++++++- .../app/src/main/jni/CMakeLists.txt | 22 +- .../app/src/main/jni/build_nibtools.sh | 97 ++++++++ .../app/src/main/jni/nibconv_jni.c | 76 ++++++ .../src/main/jni/nibtools_android/mnibarch.h | 18 ++ .../src/main/jni/nibtools_android/opencbm.h | 13 + .../app/src/main/res/layout/activity_main.xml | 47 +++- .../app/src/main/res/values-de/strings.xml | 7 + .../app/src/main/res/values/strings.xml | 7 + .../res/nibtools-91344e0ee3.tar.gz | Bin 0 -> 135995 bytes .../res/schwert-und-magie-anleitung.pdf | Bin 0 -> 492514 bytes 16 files changed, 765 insertions(+), 10 deletions(-) create mode 100644 SchwertUndMagieOnPebbleCompanionApp/app/src/main/assets/anleitung.txt create mode 100644 SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/build_nibtools.sh create mode 100644 SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibconv_jni.c create mode 100644 SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools_android/mnibarch.h create mode 100644 SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools_android/opencbm.h create mode 100644 SchwertUndMagieOnPebbleCompanionApp/res/nibtools-91344e0ee3.tar.gz create mode 100644 SchwertUndMagieOnPebbleCompanionApp/res/schwert-und-magie-anleitung.pdf diff --git a/.gitignore b/.gitignore index 23d25ab..9232cab 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 31681cf..9e9678c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | | `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 @@ -127,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-.tar.gz` + and cross-compiles the files above into `nibtools-libs//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. diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts index f5a69fd..7f1c9c1 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts +++ b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts @@ -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//libnibtools.a already exists. +// The tarball at /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") } } diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/assets/anleitung.txt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/assets/anleitung.txt new file mode 100644 index 0000000..ef5de49 --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/assets/anleitung.txt @@ -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! diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt index bd31e3f..8ffb266 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt @@ -61,6 +61,13 @@ class C64Engine { /** 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") } diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt index 5f72ec3..73cb8fe 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt @@ -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 ---------------------------------------------------------- override fun onCreate(savedInstanceState: Bundle?) { @@ -177,11 +291,15 @@ class MainActivity : AppCompatActivity() { } findViewById