Add hero save editor with D64 format documentation

Pencil button next to each hero disk opens an editor dialog that parses
  the D64 directly, lists all PRG save files in a spinner, and lets the
  user adjust all nine stats (Vitalität, grade, six percentage stats) with
  +/− buttons. Changes to multiple heroes in one session are written back
  together on Save.

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

  D64 sector layout helpers extracted to class level so createBlankD64 and
  the new editor share the same offset function. Save format documented in
  res/HELD_D64_FORMAT.md. Editor strings localised in EN/DE.
This commit is contained in:
ml
2026-06-14 09:46:17 +02:00
parent 958ab77c58
commit 755e7758e4
5 changed files with 251 additions and 22 deletions
@@ -12,9 +12,12 @@ import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.view.Choreographer import android.view.Choreographer
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@@ -64,6 +67,24 @@ class MainActivity : AppCompatActivity() {
// ---- disk slots --------------------------------------------------------- // ---- disk slots ---------------------------------------------------------
// ---- D64 helpers (shared by createBlankD64 and the hero editor) ----------
private val d64Spt = intArrayOf(
0,
21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21,
19, 19, 19, 19, 19, 19, 19,
18, 18, 18, 18, 18, 18,
17, 17, 17, 17, 17
)
private fun d64Off(track: Int, sector: Int): Int {
var off = 0
for (t in 1 until track) off += d64Spt[t] * 256
return off + sector * 256
}
// ---- disk slots ---------------------------------------------------------
private val diskSlots = listOf( private val diskSlots = listOf(
DiskSlot(1, 'A', R.id.btn_ep_1a, "Folge 1: Das geheimnisvolle Kraut"), DiskSlot(1, 'A', R.id.btn_ep_1a, "Folge 1: Das geheimnisvolle Kraut"),
DiskSlot(1, 'B', R.id.btn_ep_1b, "Folge 2: Der unheimliche Tempel"), DiskSlot(1, 'B', R.id.btn_ep_1b, "Folge 2: Der unheimliche Tempel"),
@@ -77,8 +98,9 @@ class MainActivity : AppCompatActivity() {
DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"), DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"),
DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"), DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"),
) )
private val episodeButtons = mutableMapOf<DiskSlot, Button>() private val episodeButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>() private val heroEditButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>()
private var activeSlot: DiskSlot? = null private var activeSlot: DiskSlot? = null
private var currentDiskPath: String? = null // path last passed to engine.loadDisk() private var currentDiskPath: String? = null // path last passed to engine.loadDisk()
@@ -187,6 +209,17 @@ class MainActivity : AppCompatActivity() {
findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) } findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) }
} }
val editButtonIds = mapOf(
R.id.btn_hero_1_edit to diskSlots.first { it.fileName == "HELD1.D64" },
R.id.btn_hero_2_edit to diskSlots.first { it.fileName == "HELD2.D64" },
R.id.btn_hero_3_edit to diskSlots.first { it.fileName == "HELD3.D64" },
)
for ((btnId, slot) in editButtonIds) {
val btn = findViewById<Button>(btnId)
heroEditButtons[slot] = btn
btn.setOnClickListener { showHeroEditor(slot) }
}
choreographer = Choreographer.getInstance() choreographer = Choreographer.getInstance()
initEmulator() initEmulator()
refreshDiskButtons() refreshDiskButtons()
@@ -285,12 +318,14 @@ class MainActivity : AppCompatActivity() {
private fun refreshDiskButtons() { private fun refreshDiskButtons() {
for (slot in diskSlots) { for (slot in diskSlots) {
val exists = diskFile(slot) != null
val btn = episodeButtons[slot] ?: continue val btn = episodeButtons[slot] ?: continue
btn.isEnabled = diskFile(slot) != null btn.isEnabled = exists
btn.backgroundTintList = if (slot == activeSlot) btn.backgroundTintList = if (slot == activeSlot)
ColorStateList.valueOf(Color.parseColor("#1A4A1A")) ColorStateList.valueOf(Color.parseColor("#1A4A1A"))
else else
ColorStateList.valueOf(Color.TRANSPARENT) ColorStateList.valueOf(Color.TRANSPARENT)
heroEditButtons[slot]?.isEnabled = exists
} }
} }
@@ -386,26 +421,10 @@ class MainActivity : AppCompatActivity() {
/** Returns a 174 848-byte blank formatted 1541 D64 image with [diskLabel] as the disk name. */ /** Returns a 174 848-byte blank formatted 1541 D64 image with [diskLabel] as the disk name. */
private fun createBlankD64(diskLabel: String): ByteArray { private fun createBlankD64(diskLabel: String): ByteArray {
// Sectors per track for a standard 35-track 1541 disk
val sectorsPerTrack = intArrayOf(
0, // index 0 unused
21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, // 1-17
19, // 18 (directory)
19, 19, 19, 19, 19, 19, // 19-24
18, 18, 18, 18, 18, 18, // 25-30
17, 17, 17, 17, 17 // 31-35
)
val data = ByteArray(174848) val data = ByteArray(174848)
fun offset(track: Int, sector: Int): Int {
var off = 0
for (t in 1 until track) off += sectorsPerTrack[t] * 256
return off + sector * 256
}
// BAM sector — track 18, sector 0 // BAM sector — track 18, sector 0
val bam = offset(18, 0) val bam = d64Off(18, 0)
data[bam + 0] = 18 // first directory track data[bam + 0] = 18 // first directory track
data[bam + 1] = 1 // first directory sector data[bam + 1] = 1 // first directory sector
data[bam + 2] = 0x41 // DOS version 'A' data[bam + 2] = 0x41 // DOS version 'A'
@@ -413,7 +432,7 @@ class MainActivity : AppCompatActivity() {
for (t in 1..35) { for (t in 1..35) {
val entry = bam + 4 + (t - 1) * 4 val entry = bam + 4 + (t - 1) * 4
val sectors = sectorsPerTrack[t] val sectors = d64Spt[t]
// All sectors free; track 18 has sectors 0 (BAM) and 1 (dir) pre-allocated // All sectors free; track 18 has sectors 0 (BAM) and 1 (dir) pre-allocated
var mask = (1 shl sectors) - 1 var mask = (1 shl sectors) - 1
var free = sectors var free = sectors
@@ -440,13 +459,194 @@ class MainActivity : AppCompatActivity() {
for (i in 167..255) data[bam + i] = 0xA0.toByte() for (i in 167..255) data[bam + i] = 0xA0.toByte()
// First directory sector — track 18, sector 1 // First directory sector — track 18, sector 1
val dir = offset(18, 1) val dir = d64Off(18, 1)
data[dir + 0] = 0x00 // no next track (end of chain) data[dir + 0] = 0x00 // no next track (end of chain)
data[dir + 1] = 0xFF.toByte() data[dir + 1] = 0xFF.toByte()
return data return data
} }
// ---- hero save editor ---------------------------------------------------
private fun petsciiToString(bytes: ByteArray): String = buildString {
for (b in bytes) {
val v = b.toInt() and 0xFF
when {
v == 0x00 || v == 0xA0 -> return@buildString
v in 0xC1..0xDA -> append((v - 0x80).toChar()) // shifted → A-Z uppercase
v in 0x41..0x5A -> append((v + 0x20).toChar()) // unshifted → a-z lowercase
v in 0x30..0x39 -> append(v.toChar())
v == 0x20 -> append(' ')
}
}
}
/** Walks the directory chain and returns every PRG file found on the disk. */
private fun findAllHeroPrgs(d64: ByteArray): List<HeroPrg> {
val result = mutableListOf<HeroPrg>()
var track = 18; var sector = 1
while (track != 0) {
val base = d64Off(track, sector)
for (i in 0 until 8) {
val e = base + 2 + i * 32
val ft = d64[e].toInt() and 0xFF
if ((ft and 0x80) != 0 && (ft and 0x0F) == 0x02) {
val t = d64[e + 1].toInt() and 0xFF
val s = d64[e + 2].toInt() and 0xFF
if (t != 0) {
val sectorOff = d64Off(t, s)
val prgBase = sectorOff + 2
val name = petsciiToString(d64.copyOfRange(prgBase + 0x02, prgBase + 0x12))
result.add(HeroPrg(
name = name,
sectorOff = sectorOff,
values = intArrayOf(
d64[prgBase + 0x12].toInt() and 0xFF,
d64[prgBase + 0x13].toInt() and 0xFF,
d64[prgBase + 0x14].toInt() and 0xFF,
d64[prgBase + 0x15].toInt() and 0xFF,
d64[prgBase + 0x16].toInt() and 0xFF,
d64[prgBase + 0x17].toInt() and 0xFF,
d64[prgBase + 0x18].toInt() and 0xFF,
d64[prgBase + 0x19].toInt() and 0xFF,
d64[prgBase + 0x1A].toInt() and 0xFF,
)
))
}
}
}
track = d64[base].toInt() and 0xFF
sector = d64[base + 1].toInt() and 0xFF
}
return result
}
private fun showHeroEditor(slot: DiskSlot) {
val file = diskFile(slot) ?: return
val d64 = try { file.readBytes() } catch (e: Exception) { return }
val heroes = findAllHeroPrgs(d64)
if (heroes.isEmpty()) {
Toast.makeText(this, getString(R.string.no_hero_save), Toast.LENGTH_SHORT).show()
return
}
val labels = arrayOf("Vitalität", "Tapferkeit", "Intelligenz", "Charme",
"Stärke", "Geschicklichkeit", "Angriffswert", "Verteidigung", "Grade")
val isPercent = booleanArrayOf(false, true, true, true, true, true, true, true, false)
val minVals = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 1)
val maxVals = intArrayOf(27, 20, 20, 20, 20, 20, 20, 20, 9)
var selectedIdx = 0
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
fun displayStr(heroIdx: Int, statIdx: Int) =
if (isPercent[statIdx]) "${heroes[heroIdx].values[statIdx] * 5}%"
else "${heroes[heroIdx].values[statIdx]}"
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(16), dp(12), dp(16), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
// Hero selector — always shown (informative even with a single hero)
val spinner = Spinner(this).apply {
adapter = ArrayAdapter(
this@MainActivity,
android.R.layout.simple_spinner_item,
heroes.map { it.name }
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(12) }
}
content.addView(spinner)
// Stat value displays — shared across hero switches
val valueTvs = Array(labels.size) { i ->
TextView(this).apply {
text = displayStr(0, i)
textSize = 13f
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
}
}
for (i in labels.indices) {
val row = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(4) }
}
row.addView(TextView(this).apply {
text = labels[i]
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
layoutParams = android.widget.LinearLayout.LayoutParams(
0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
row.addView(Button(this).apply {
text = ""; textSize = 14f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
setOnClickListener {
val v = heroes[selectedIdx].values
if (v[i] > minVals[i]) { v[i]--; valueTvs[i].text = displayStr(selectedIdx, i) }
}
})
row.addView(valueTvs[i])
row.addView(Button(this).apply {
text = "+"; textSize = 14f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
setOnClickListener {
val v = heroes[selectedIdx].values
if (v[i] < maxVals[i]) { v[i]++; valueTvs[i].text = displayStr(selectedIdx, i) }
}
})
content.addView(row)
}
// Switching heroes refreshes all value views
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p: AdapterView<*>?, v: android.view.View?, pos: Int, id: Long) {
selectedIdx = pos
for (i in labels.indices) valueTvs[i].text = displayStr(selectedIdx, i)
}
override fun onNothingSelected(p: AdapterView<*>?) {}
}
AlertDialog.Builder(this)
.setTitle(getString(R.string.editor_title, slot.label))
.setView(android.widget.ScrollView(this).apply {
addView(content)
setBackgroundColor(Color.parseColor("#1A1A2E"))
})
.setPositiveButton(R.string.editor_save) { _, _ ->
for (hero in heroes) {
val prgBase = hero.sectorOff + 2
for (i in hero.values.indices)
d64[prgBase + 0x12 + i] = hero.values[i].toByte()
}
try {
file.writeBytes(d64)
appendLog("Saved: ${heroes.joinToString { it.name }} (${slot.label})")
} catch (e: Exception) {
appendLog("Save failed: ${slot.label}")
Log.e(TAG, "Hero editor write failed", e)
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
// ---- splash screen ------------------------------------------------------ // ---- splash screen ------------------------------------------------------
private fun dismissSplashDelayed() { private fun dismissSplashDelayed() {
@@ -543,3 +743,8 @@ class CompanionServer(
return response return response
} }
} }
// ---------------------------------------------------------------------------
/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray)
@@ -206,6 +206,10 @@
<Button android:id="@+id/btn_hero_1_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_1_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_1_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -217,6 +221,10 @@
<Button android:id="@+id/btn_hero_2_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_2_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_2_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -228,6 +236,10 @@
<Button android:id="@+id/btn_hero_3_new" style="?attr/materialButtonOutlinedStyle" <Button android:id="@+id/btn_hero_3_new" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" /> android:layout_marginStart="4dp" android:text="@string/btn_new" android:textSize="11sp" />
<Button android:id="@+id/btn_hero_3_edit" style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:minWidth="0dp" android:padding="0dp"
android:text="✏" android:textSize="14sp" android:enabled="false" />
</LinearLayout> </LinearLayout>
<View <View
@@ -29,4 +29,10 @@
<!-- Hero disk creation log --> <!-- Hero disk creation log -->
<string name="hero_disk_created">Erstellt: %1$s</string> <string name="hero_disk_created">Erstellt: %1$s</string>
<string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string> <string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string>
<!-- Hero save editor -->
<string name="editor_title">%1$s bearbeiten</string>
<string name="editor_save">Speichern</string>
<string name="no_hero_save">Noch kein Heldspeicherstand auf dieser Diskette</string>
<string name="stat_name">Name</string>
</resources> </resources>
@@ -31,4 +31,10 @@
<!-- Hero disk creation log --> <!-- Hero disk creation log -->
<string name="hero_disk_created">Created: %1$s</string> <string name="hero_disk_created">Created: %1$s</string>
<string name="hero_disk_create_failed">Failed to create: %1$s</string> <string name="hero_disk_create_failed">Failed to create: %1$s</string>
<!-- Hero save editor -->
<string name="editor_title">Edit %1$s</string>
<string name="editor_save">Save</string>
<string name="no_hero_save">No hero save found on this disk yet</string>
<string name="stat_name">Name</string>
</resources> </resources>