feat: render PS1 memory-card saves over nostr (kind 38192)

feat: animate the PS1 BIOS save icon on kind-38192 cards
This commit is contained in:
davotoula
2026-07-06 00:35:58 +02:00
parent 567d98cbcb
commit c0cc2b0245
11 changed files with 620 additions and 0 deletions
@@ -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)
}
@@ -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)
}
@@ -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?,
)
@@ -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) {
+3
View File
@@ -3495,6 +3495,9 @@
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1 memory card save</string>
<string name="ps1_save_block">block %1$d</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Police</string>
<string name="road_event_speed_camera">Speed camera</string>
@@ -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, `<memory card id>-<block number>`.
* - `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 '<title>' (<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
}
}
@@ -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)
}
}
@@ -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"),
@@ -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)
@@ -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())
}
}
@@ -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(""))
}
}