From c0cc2b0245c78fa61bc58f0c0cea3ab26cb48e03 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 22:57:13 +0200 Subject: [PATCH] feat: render PS1 memory-card saves over nostr (kind 38192) feat: animate the PS1 BIOS save icon on kind-38192 cards --- .../amethyst/model/LocalCache.kt | 5 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/Ps1Save.kt | 186 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + amethyst/src/main/res/values/strings.xml | 3 + .../experimental/ps1saves/Ps1SaveEvent.kt | 98 +++++++++ .../experimental/ps1saves/Ps1SaveIcon.kt | 109 ++++++++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../quartz/utils/EventFactory.kt | 2 + .../experimental/ps1saves/Ps1SaveEventTest.kt | 100 ++++++++++ .../experimental/ps1saves/Ps1SaveIconTest.kt | 105 ++++++++++ 11 files changed, 620 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIcon.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ps1saves/Ps1SaveIconTest.kt 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 '' (<filename>)`. + */ +@Immutable +class Ps1SaveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array<Array<String>>, + 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<IntArray>, +) { + 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<Ps1SaveEvent>(sampleEvent()) + } + + @Test + fun kind38192IsNowKnown() { + assertTrue(EventFactory.isKnownKind(Ps1SaveEvent.KIND), "kind 38192 should be a known kind") + } + + @Test + fun parsesPs1SaveFields() { + val event = sampleEvent() + assertIs<Ps1SaveEvent>(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<Ps1SaveEvent>(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("")) + } +}