diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 101451360d..b5c27ae39f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -77,6 +77,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -3494,6 +3495,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeRegularEvent(event, relay, wasVerified)
}
+ is Ps1SaveEvent -> {
+ consumeBaseReplaceable(event, relay, wasVerified)
+ }
+
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index 748e848c32..9939907e47 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -169,6 +169,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
+import com.vitorpamplona.amethyst.ui.note.types.RenderPs1Save
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
@@ -246,6 +247,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -1387,6 +1389,10 @@ private fun RenderNoteRow(
RenderBirdDetection(baseNote)
}
+ is Ps1SaveEvent -> {
+ RenderPs1Save(baseNote)
+ }
+
is RoadEventReportEvent -> {
RenderRoadEventReport(baseNote)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt
new file mode 100644
index 0000000000..5676fc4b9e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.theme.Size24dp
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveIcon
+import kotlinx.coroutines.delay
+
+/** How many hex characters of the block to preview (the full block is ~16K chars). */
+private const val HEX_PREVIEW_CHARS = 192
+
+/** Floppy-disk emoji fallback for blocks without a decodable save icon. */
+private const val FLOPPY_PREFIX = "๐พ "
+
+/** Milliseconds per animation frame, roughly the BIOS memory-card screen cadence. */
+private const val ICON_FRAME_MILLIS = 250L
+
+/**
+ * Minimal, fixed-size card for a PS1 memory-card save block (kind 38192).
+ *
+ * The content is a full 8 KiB memory-card block, hex-encoded (~16K characters),
+ * so the card never renders it whole: it shows the save's own 16ร16 icon from
+ * the title frame (animated exactly like the PS1 BIOS memory-card screen; a
+ * floppy emoji when the block has none), the save title, a metadata line
+ * (game product code, region, block number), and a short monospace hex preview
+ * clamped to a few lines โ no expansion, no network calls. The card is
+ * identical in the feed and the opened view, so it takes no makeItShort flag.
+ */
+@Composable
+fun RenderPs1Save(baseNote: Note) {
+ val noteEvent = baseNote.event as? Ps1SaveEvent ?: return
+
+ val save =
+ remember(noteEvent) {
+ Ps1SaveInfo(
+ title = noteEvent.saveTitle(),
+ filename = noteEvent.filename(),
+ region = noteEvent.region(),
+ blockNumber = noteEvent.blockNumber(),
+ icon = noteEvent.icon(),
+ hexPreview =
+ noteEvent.content
+ .take(HEX_PREVIEW_CHARS)
+ .chunked(4)
+ .joinToString(" ")
+ .ifBlank { null },
+ )
+ }
+
+ Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (save.icon != null) {
+ Ps1SaveIconImage(save.icon)
+ Spacer(Modifier.width(8.dp))
+ }
+ Text(
+ text = (if (save.icon == null) FLOPPY_PREFIX else "") + (save.title ?: stringResource(R.string.ps1_save_title)),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ val details =
+ listOfNotNull(
+ save.filename,
+ save.region,
+ save.blockNumber?.let { stringResource(R.string.ps1_save_block, it) },
+ ).joinToString(" ยท ")
+
+ if (details.isNotEmpty()) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = details,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ if (save.hexPreview != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = save.hexPreview,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+/**
+ * The save's 16ร16 icon, cycling its animation frames like the PS1 BIOS
+ * memory-card screen. Scaled with nearest-neighbor so the pixels stay crisp.
+ */
+@Composable
+private fun Ps1SaveIconImage(icon: Ps1SaveIcon) {
+ val frames =
+ remember(icon) {
+ icon.frames.map { pixels ->
+ Bitmap
+ .createBitmap(pixels, Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE, Bitmap.Config.ARGB_8888)
+ .asImageBitmap()
+ }
+ }
+
+ var frameIndex by remember(icon) { mutableIntStateOf(0) }
+
+ if (frames.size > 1) {
+ LaunchedEffect(icon) {
+ while (true) {
+ delay(ICON_FRAME_MILLIS)
+ frameIndex = (frameIndex + 1) % frames.size
+ }
+ }
+ }
+
+ Image(
+ bitmap = frames[frameIndex],
+ contentDescription = null,
+ modifier = Modifier.size(Size24dp),
+ filterQuality = FilterQuality.None,
+ )
+}
+
+/** Tag values parsed once per event for the save card. */
+private class Ps1SaveInfo(
+ val title: String?,
+ val filename: String?,
+ val region: String?,
+ val blockNumber: Int?,
+ val icon: Ps1SaveIcon?,
+ val hexPreview: String?,
+)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
index e452ea6032..a2ccc498b7 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
@@ -192,6 +192,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
+import com.vitorpamplona.amethyst.ui.note.types.RenderPs1Save
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
@@ -264,6 +265,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -976,6 +978,8 @@ private fun FullBleedNoteCompose(
RenderBirdex(baseNote)
} else if (noteEvent is BirdDetectionEvent) {
RenderBirdDetection(baseNote)
+ } else if (noteEvent is Ps1SaveEvent) {
+ RenderPs1Save(baseNote)
} else if (noteEvent is RoadEventReportEvent) {
RenderRoadEventReport(baseNote)
} else if (noteEvent is RoadEventConfirmationEvent) {
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 40ab075e01..b8810a8640 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -3495,6 +3495,9 @@
- %1$s +%2$d more
- %1$s +%2$d more
+
+ PS1 memory card save
+ block %1$d
Police
Speed camera
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEvent.kt
new file mode 100644
index 0000000000..ad7061809a
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEvent.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.ps1saves
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip50Search.SearchableEvent
+
+/**
+ * PlayStation 1 memory-card save block (kind 38192).
+ *
+ * An experimental **addressable** kind with a single known publisher โ a
+ * "PS1 memory cards over nostr" project โ and **no** NIP; the schema below is
+ * derived from events seen in the wild. Each event carries one raw 8 KiB
+ * memory-card block, hex-encoded in the content (the kind number itself echoes
+ * the 8192-byte block size). Being addressable, re-saving a block replaces the
+ * previous event for the same `(pubkey, kind, d)` โ memory-card semantics.
+ *
+ * Tags seen in the wild:
+ *
+ * - `d` โ unique block address, `-`.
+ * - `m` โ the memory card this block belongs to.
+ * - `block` โ block number within the card, as a decimal string.
+ * - `x` โ SHA-256 of the block data.
+ * - `state` โ the block's position in a multi-block save chain (e.g. `first`).
+ * - `filename` โ the PS1 save filename, i.e. the game's product code
+ * (e.g. `BASCUS-00001SOFTCARD`).
+ * - `region` โ the game region (e.g. `America`).
+ * - `title` โ the save's human-readable name.
+ * - `alt` โ NIP-31 fallback, e.g. `PS1 save '' ()`.
+ */
+@Immutable
+class Ps1SaveEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
+ SearchableEvent {
+ override fun indexableContent() = listOfNotNull(summary(), saveTitle(), filename()).joinToString("\n")
+
+ /** Human-readable save name, from the `title` tag (may be null). */
+ fun saveTitle() = tags.firstTagValue("title")
+
+ /** PS1 save filename / game product code, from the `filename` tag (may be null). */
+ fun filename() = tags.firstTagValue("filename")
+
+ /** Game region, from the `region` tag (may be null). */
+ fun region() = tags.firstTagValue("region")
+
+ /** Identifier of the memory card this block belongs to, from the `m` tag (may be null). */
+ fun memoryCardId() = tags.firstTagValue("m")
+
+ /** Block number within the card, from the `block` tag (null when missing or not a number). */
+ fun blockNumber() = tags.firstTagValue("block")?.toIntOrNull()
+
+ /** Position of this block in a multi-block save chain, from the `state` tag (may be null). */
+ fun blockState() = tags.firstTagValue("state")
+
+ /** SHA-256 of the block data, from the `x` tag (may be null). */
+ fun blockHash() = tags.firstTagValue("x")
+
+ /** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */
+ fun summary() = tags.alt()
+
+ /**
+ * The BIOS-style animated save icon decoded from this block's title frame.
+ * Null unless this is a save's first block with a valid icon (see [Ps1SaveIcon]).
+ */
+ fun icon() = Ps1SaveIcon.parse(content)
+
+ companion object {
+ const val KIND = 38192
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIcon.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIcon.kt
new file mode 100644
index 0000000000..9bc5b22ead
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIcon.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.ps1saves
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
+
+/**
+ * The save icon embedded in a PS1 save's title frame โ the sprite the PS1 BIOS
+ * memory-card screen shows, animated, when the console boots without a disc.
+ *
+ * The title frame (first 128 bytes of a save's first block) lays it out as:
+ *
+ * - `0x00` โ magic `SC`.
+ * - `0x02` โ icon display flag: `0x11`/`0x12`/`0x13` = 1/2/3 animation frames.
+ * - `0x60` โ 16-entry CLUT, each a little-endian 16-bit color
+ * (bits 0-4 red, 5-9 green, 10-14 blue); raw `0x0000` is transparent.
+ * - `0x80` โ the frames, each 16ร16 pixels at 4bpp (128 bytes), low nibble
+ * first; pixel values index the CLUT.
+ *
+ * [frames] holds one 256-entry row-major ARGB pixel array per animation frame,
+ * ready to blit into a bitmap.
+ */
+@Immutable
+class Ps1SaveIcon(
+ val frames: List,
+) {
+ companion object {
+ /** Icon edge length in pixels. */
+ const val SIZE = 16
+
+ /** Pixels per frame ([SIZE] ร [SIZE]). */
+ const val PIXELS = SIZE * SIZE
+
+ private const val FLAG_OFFSET = 2
+ private const val PALETTE_OFFSET = 0x60
+ private const val PALETTE_ENTRIES = 16
+ private const val FRAMES_OFFSET = 0x80
+ private const val FRAME_BYTES = PIXELS / 2
+
+ /**
+ * Decodes the icon from a save block's hex-encoded content. Null when the
+ * content is not hex, lacks the `SC` magic or a known display flag, or is
+ * too short for the frame count it declares.
+ */
+ fun parse(blockHex: String): Ps1SaveIcon? {
+ val neededForAllFrames = (FRAMES_OFFSET + 3 * FRAME_BYTES) * 2
+ val bytes = blockHex.take(neededForAllFrames).hexToByteArrayOrNull() ?: return null
+
+ if (bytes.size < FRAMES_OFFSET) return null
+ if (bytes[0] != 'S'.code.toByte() || bytes[1] != 'C'.code.toByte()) return null
+
+ val frameCount =
+ when (bytes[FLAG_OFFSET].toInt() and 0xFF) {
+ 0x11 -> 1
+ 0x12 -> 2
+ 0x13 -> 3
+ else -> return null
+ }
+ if (bytes.size < FRAMES_OFFSET + frameCount * FRAME_BYTES) return null
+
+ val palette =
+ IntArray(PALETTE_ENTRIES) { entry ->
+ val lo = bytes[PALETTE_OFFSET + entry * 2].toInt() and 0xFF
+ val hi = bytes[PALETTE_OFFSET + entry * 2 + 1].toInt() and 0xFF
+ argbFromClut((hi shl 8) or lo)
+ }
+
+ val frames =
+ List(frameCount) { frame ->
+ val base = FRAMES_OFFSET + frame * FRAME_BYTES
+ IntArray(PIXELS) { pixel ->
+ val packed = bytes[base + pixel / 2].toInt() and 0xFF
+ palette[if (pixel % 2 == 0) packed and 0xF else packed shr 4]
+ }
+ }
+
+ return Ps1SaveIcon(frames)
+ }
+
+ private fun argbFromClut(raw: Int): Int {
+ if (raw == 0) return 0
+ val r = expand5To8(raw and 0x1F)
+ val g = expand5To8((raw shr 5) and 0x1F)
+ val b = expand5To8((raw shr 10) and 0x1F)
+ return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
+ }
+
+ private fun expand5To8(v: Int) = (v shl 3) or (v shr 2)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
index dd58d1936a..774dd47b32 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -560,6 +561,7 @@ object KindNames {
UserGraspListEvent.KIND to KindName("GRASP Servers", "34"),
BirdexEvent.KIND to KindName("Birdex", null),
BirdDetectionEvent.KIND to KindName("Bird Detection", null),
+ Ps1SaveEvent.KIND to KindName("PS1 Save", null),
NwcInfoEvent.KIND to KindName("NWC Info", "47"),
RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"),
RootSiteEvent.KIND to KindName("Website Root", "5A"),
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
index cce8601fa1..e42c2b54ff 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
@@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
+import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -568,6 +569,7 @@ class EventFactory {
PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig)
PodcastMetadataEvent.KIND -> PodcastMetadataEvent(id, pubKey, createdAt, tags, content, sig)
PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig)
+ Ps1SaveEvent.KIND -> Ps1SaveEvent(id, pubKey, createdAt, tags, content, sig)
AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig)
FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig)
Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig)
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEventTest.kt
new file mode 100644
index 0000000000..063db33c00
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEventTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.ps1saves
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.utils.EventFactory
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class Ps1SaveEventTest {
+ // Tags and content prefix from a real event seen in the wild
+ // (6a21107f33e5aa5f02839812b6d1a5387cfa3df0e30398798430f3f3814e65e7 on nos.lol);
+ // the full content is an 8 KiB memory-card block, truncated here, so id/sig
+ // are zeroed placeholders (EventFactory does not verify them).
+ private fun sampleEvent(): Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "f1fc95d1634e54f7aa7fe816496b3f26b8e6100440c4500de1c2dfc6be2f60bf",
+ createdAt = 1_783_151_940L,
+ kind = Ps1SaveEvent.KIND,
+ tags =
+ arrayOf(
+ arrayOf("d", "synctest-f1fc95d1-1"),
+ arrayOf("m", "synctest-f1fc95d1"),
+ arrayOf("block", "1"),
+ arrayOf("x", "4235e8c1d7164927b78dd50e0a12acbea04b2d80f0864fc3298e7d84225d8f41"),
+ arrayOf("state", "first"),
+ arrayOf("filename", "BASCUS-00001SOFTCARD"),
+ arrayOf("region", "America"),
+ arrayOf("title", "SYNCED OVER NOST Card"),
+ arrayOf("alt", "PS1 save 'SYNCED OVER NOST Card' (BASCUS-00001SOFTCARD)"),
+ ),
+ content = "5343110153594e434544204f564552204e4f53542043617264000000000000",
+ sig = "00".repeat(64),
+ )
+
+ @Test
+ fun factoryBuildsPs1SaveForKind38192() {
+ assertIs(sampleEvent())
+ }
+
+ @Test
+ fun kind38192IsNowKnown() {
+ assertTrue(EventFactory.isKnownKind(Ps1SaveEvent.KIND), "kind 38192 should be a known kind")
+ }
+
+ @Test
+ fun parsesPs1SaveFields() {
+ val event = sampleEvent()
+ assertIs(event)
+
+ assertEquals("SYNCED OVER NOST Card", event.saveTitle())
+ assertEquals("BASCUS-00001SOFTCARD", event.filename())
+ assertEquals("America", event.region())
+ assertEquals("synctest-f1fc95d1", event.memoryCardId())
+ assertEquals(1, event.blockNumber())
+ assertEquals("first", event.blockState())
+ assertEquals("4235e8c1d7164927b78dd50e0a12acbea04b2d80f0864fc3298e7d84225d8f41", event.blockHash())
+ assertEquals("PS1 save 'SYNCED OVER NOST Card' (BASCUS-00001SOFTCARD)", event.summary())
+ assertEquals("synctest-f1fc95d1-1", event.dTag())
+ }
+
+ @Test
+ fun blockNumberIsNullWhenTagIsMissingOrNotANumber() {
+ val event: Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "00".repeat(32),
+ createdAt = 1_783_151_940L,
+ kind = Ps1SaveEvent.KIND,
+ tags = arrayOf(arrayOf("d", "card-1"), arrayOf("block", "not-a-number")),
+ content = "",
+ sig = "00".repeat(64),
+ )
+ assertIs(event)
+
+ assertEquals(null, event.blockNumber())
+ assertEquals(null, event.saveTitle())
+ }
+}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIconTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIconTest.kt
new file mode 100644
index 0000000000..5a9db4d392
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIconTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.ps1saves
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class Ps1SaveIconTest {
+ // First 512 bytes (title frame + 3 icon frames) of the "SPYRO THE DRAGON"
+ // save block seen in the wild
+ // (event 178a9a139155f6e3aa799df059149facc635c3c3ae0495e9a3cdc600006b4c43
+ // on relay.cyberguy.fyi). Magic "SC", display flag 0x13 = 3 frames.
+ private val spyroBlock =
+ "534313018272826f82788271826e814082738267826481408263827182608266" +
+ "826e826d00000000000000000000000000000000000000000000000000000000" +
+ "0000000000000000000000000000000000000000000000000000000000000000" +
+ "0000bc142e04df393f25590833045f4a00000000000000000000000000004954" +
+ "0000006651000000001011141111000010414411111111004444141111111155" +
+ "3333545555555555333353555555552530335355555555054033535555555506" +
+ "1041135555556506005551666666660000505522222222000000552622220200" +
+ "0000502522220000000000662202000000000060260000000000000002000000" +
+ "0000007033000000000077773313000070777737333301007377773333334355" +
+ "3373374444444444333313111111111130331311111111014033131111111106" +
+ "1041131111111106005551666666260000505522222222000000552622220200" +
+ "0000502522220000000000662202000000000060260000000000000002000000" +
+ "0000001115000000001011441111000000414414111101004444141111115155" +
+ "4144444144444444414474777777777741447477777777331044347777777707" +
+ "5015347777777707005545334344140000505544444411000000554144440100" +
+ "0000504544440000000000114404000000000060140000000000000006000000"
+
+ @Test
+ fun parsesThreeAnimationFramesFromDisplayFlag() {
+ val icon = Ps1SaveIcon.parse(spyroBlock)
+ assertNotNull(icon)
+ assertEquals(3, icon.frames.size)
+ assertEquals(Ps1SaveIcon.PIXELS, icon.frames[0].size)
+ }
+
+ @Test
+ fun decodesPaletteIndexedPixelsToArgb() {
+ val icon = Ps1SaveIcon.parse(spyroBlock)
+ assertNotNull(icon)
+
+ val frame = icon.frames[0]
+ // Palette index 0 (raw 0x0000) is transparent.
+ assertEquals(0, frame[0])
+ // Pixel (6,0) uses palette entry 6 (raw 0x0433 -> RGB555 R=19 G=1 B=1).
+ assertEquals(0xFF9C0808.toInt(), frame[6])
+ // Pixel (8,0) uses palette entry 1 (raw 0x14bc -> RGB555 R=28 G=5 B=5).
+ assertEquals(0xFFE72929.toInt(), frame[8])
+ // Pixel (9,0) uses palette entry 5 (raw 0x0859 -> RGB555 R=25 G=2 B=2).
+ assertEquals(0xFFCE1010.toInt(), frame[9])
+ }
+
+ @Test
+ fun animationFramesDiffer() {
+ val icon = Ps1SaveIcon.parse(spyroBlock)
+ assertNotNull(icon)
+
+ assertFalse(icon.frames[0].contentEquals(icon.frames[1]))
+ }
+
+ @Test
+ fun parseIsNullWithoutTheScMagic() {
+ assertNull(Ps1SaveIcon.parse("4d43" + spyroBlock.drop(4)))
+ }
+
+ @Test
+ fun parseIsNullForAnUnknownDisplayFlag() {
+ assertNull(Ps1SaveIcon.parse(spyroBlock.take(4) + "00" + spyroBlock.drop(6)))
+ }
+
+ @Test
+ fun parseIsNullWhenTheBlockIsTooShortForItsFrames() {
+ // Valid header claiming 3 frames but content ends before the frame data.
+ assertNull(Ps1SaveIcon.parse(spyroBlock.take(300)))
+ }
+
+ @Test
+ fun parseIsNullOnNonHexContent() {
+ assertNull(Ps1SaveIcon.parse("not hex at all"))
+ assertNull(Ps1SaveIcon.parse(""))
+ }
+}