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
@@ -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 {
if (name.startsWith("configureCMake") || name.startsWith("buildCMake") ||
name.startsWith("externalNativeBuild")) {
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'. */
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") }
@@ -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<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 {
@@ -548,7 +666,13 @@ 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() {
@@ -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) {
val ts = timeFormat.format(Date())
tvLog.text = "[$ts] $entry\n${tvLog.text}"
@@ -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)
@@ -48,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,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: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"
@@ -259,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"
@@ -11,12 +11,19 @@
<!-- 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>
@@ -13,12 +13,19 @@
<!-- 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>