mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
@@ -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?,
|
||||
)
|
||||
+4
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+98
@@ -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
|
||||
}
|
||||
}
|
||||
+109
@@ -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)
|
||||
|
||||
+100
@@ -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())
|
||||
}
|
||||
}
|
||||
+105
@@ -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(""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user