feat: render shards, the objects hidden at a place (kind 3330)

DECK-0003 §3.2 is two containers for one format, not two formats, so a
shard reads through the same parser and draws through the same card as a
standalone object. SnoShardEvent adds the container and the C-tag
coordinate claim and delegates every rule to SnoParser.

What is not implemented is opening a bag. A shard published today is an
item inside a kind 33330 bag whose payload is AES-256-GCM ciphertext
keyed to a cyberspace region, so reading one means §2 coordinates, §7.2
key derivation and §7.4 discovery scanning — the protocol, not a
renderer. §7.6 is explicit that a failed decryption is not an error in
the bag.

A reader for the pre-deck tag form was written and then removed. Every
3330 publicly reachable on a relay predates the deck: 21 events, one
pubkey, all inside one minute on 2025-01-16, empty content, geometry in
vertices/colors/indices tags. Rendering the corpus showed what the
decoder bought — one black triangle and one black dot, two shapes across
21 events, every colour 0,0,0 — against the cyberspace readme saying v1
drafts "are not a valid basis for new implementations". Reverse-
engineering a deprecated encoding no current spec describes was not worth
that, so it went.

The consequence, stated rather than discovered later: no publicly
reachable 3330 renders today. The current ones are sealed in bags and the
old ones are v1.

Note the two unrelated version numbers. The payload's own `v: 1`
(DECK-0003 §2) stays supported because the deck requires it — "a reader
MUST support both" — and is the SNO format's version, not Cyberspace v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
Vitor Pamplona
2026-09-22 00:09:34 +00:00
co-authored by Claude Opus 5
parent 7dc3d5caa8
commit 94bc8864d6
8 changed files with 275 additions and 6 deletions
+26 -6
View File
@@ -467,7 +467,7 @@ Built in this order, each step verified before the next:
angle would otherwise be a half-megabyte cache entry), `SnoObjectCard` /
`SnoAvatarCard`, and a new `PlatformImage.toComposeImageBitmap()` beside the
existing Coil bridge.
4. `amethyst/…/note/types/SnoObject.kt` + `SnoAvatar.kt`, dispatched from
4. `amethyst/…/note/types/SnoObject.kt`, `SnoAvatar.kt` + `SnoShard.kt`, dispatched from
`NoteCompose` and `ThreadFeedView`, stored addressably/replaceably in
`LocalCache`, fetcher registered in both image loaders.
@@ -514,11 +514,31 @@ are where the risk is, and they are testable without a device.
of our code will check the wrong arbiter first.
- **D1b — `name`.** Required in §1.1's table, enforced by neither reference
implementation (§2.6). Treat as optional with a fallback.
- **D2 — `kind 3330` bag items.** Recommend **defer**, on `CYBERSPACE_V2.md` §7.6
rather than on the DECK: a current shard is an item inside an AES-256-GCM bag keyed
to a Cyberspace region, so reading one means implementing §2 coordinates, §7.2 key
derivation and §7.4 discovery. That is the protocol, not a renderer. The two 3330s
sitting on relays are v1-era leftovers.
- **D2 — `kind 3330` bag items. Settled: the container ships, the bag does not.**
`SnoShardEvent` reads a shard handed over directly — quoted in a note, or fetched
by id — through the same parser and the same card as a standalone object, because
§3.2 is two containers for one format rather than two formats. What is *not*
implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext
keyed to a Cyberspace region, so reading one means §2 coordinates, §7.2 key
derivation and §7.4 discovery scanning. That is the protocol, not a renderer, and
§7.6 is explicit that a failed decryption "MUST NOT be treated as an error in the
bag".
**The v1 tag form was built and then removed, deliberately.** Every `kind 3330`
publicly reachable on a relay predates the deck: 21 events, one pubkey, all inside
one minute on 2025-01-16, empty content, geometry in `vertices`/`colors`/`indices`
tags as flat comma-separated decimals. A reader for it took about 200 lines and
worked — and then rendering the whole corpus showed what it buys: **one black
triangle and one black dot**, two distinct shapes between the 21 events, every
colour `0,0,0`. The cyberspace readme settles it: *"Cyberspace v1 drafts are
deprecated and archived. They are not a valid basis for new implementations."* A
reverse-engineered decoder for a deprecated encoding that no current spec
describes is a maintenance liability priced against two black shapes, so it went.
The consequence, stated plainly: **no publicly reachable 3330 renders today.** The
current ones are sealed in bags and the old ones are v1. The code is right for the
shards the current client writes, and is exercised by tests rather than by the
network.
- **D3 — publishing.** Recommend **read-only first**. Appendix B.4 is right that the
deliverable is software that emits the format, but a modeling tool is a screen, not
a feature, and the reader is what makes objects visible at all. Build the writer
@@ -215,6 +215,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRootNappletEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoShard
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
@@ -261,6 +262,7 @@ import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1379,6 +1381,10 @@ private fun RenderNoteRow(
RenderSnoAvatar(baseNote)
}
is SnoShardEvent -> {
RenderSnoShard(baseNote)
}
is ChessGameEvent -> {
RenderChessGame(
baseNote,
@@ -0,0 +1,80 @@
/*
* 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 androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
private val VIEWER_HEIGHT = 360.dp
/**
* Entry for a shard: an object someone hid at a place (DECK-0003 §3.2, kind
* 3330).
*
* The same payload and the same rules as a standalone object — only the
* container differs — so this renders through the same card. What it does not
* do is open a bag: a shard still sealed in its `kind 33330` bag is ciphertext
* until its region key is derived, which is the cyberspace protocol rather than
* a renderer.
*/
@Composable
fun RenderSnoShard(baseNote: Note) {
val noteEvent = baseNote.event as? SnoShardEvent ?: return
val parsed = remember(noteEvent) { noteEvent.shard() }
when (parsed) {
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
is SnoResult.Valid -> {
var turning by remember(noteEvent) { mutableStateOf(false) }
SnoObjectCard(
payload = parsed.payload,
eventId = noteEvent.id,
onClick = { turning = true },
)
if (turning) {
Dialog(onDismissRequest = { turning = false }) {
SnoObjectViewer(
payload = parsed.payload,
eventId = noteEvent.id,
contentDescription = parsed.payload.name.ifBlank { null },
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
)
}
}
}
}
}
@@ -227,6 +227,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoShard
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
@@ -268,6 +269,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1072,6 +1074,8 @@ private fun FullBleedNoteCompose(
RenderSnoObject(baseNote)
} else if (noteEvent is SnoAvatarEvent) {
RenderSnoAvatar(baseNote)
} else if (noteEvent is SnoShardEvent) {
RenderSnoShard(baseNote)
} else if (noteEvent is Ps1SaveEvent) {
RenderPs1Save(baseNote)
} else if (noteEvent is GeocacheListingEvent) {
@@ -140,6 +140,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -3958,6 +3959,7 @@ open class EventCache :
is GitPullRequestEvent,
is GitPullRequestUpdateEvent,
is GitStatusEvent,
is SnoShardEvent,
is ChessGameEvent,
is JesterEvent,
is HighlightEvent,
@@ -0,0 +1,81 @@
/*
* 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.cyberspace.deck0003Sno
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* DECK-0003 §3.2 — an object hidden at a place: a `kind 3330` bag item, whose
* `content` is the payload of §1 exactly as a `kind 33331` object carries it.
*
* Regular, and deliberately so. An item in a bag is a thing someone hid at a
* place and someone else found there; it must be exactly what it was when it
* was found, and its id must keep meaning what it meant. The same payload
* therefore travels under two kinds according to what is being done with it:
* 33331 for an object its author is still working on, 3330 for one that has
* been put somewhere. That is two containers for one format, not two ways of
* writing the format — which is why this class adds a container and a
* coordinate and delegates every rule to [SnoParser].
*
* **Most shards are not reachable, and that is by design.** A shard is an item
* inside a `kind 33330` bag (`CYBERSPACE_V2.md` §7.6) whose payload is
* AES-256-GCM ciphertext keyed to the region it was hidden in, so finding one
* means deriving that region's key: the coordinate system of §2, the key
* derivation of §7.2 and the discovery scanning of §7.4. Amethyst implements
* none of that and a reader without the key sees base64 and nothing else — §7.6
* is explicit that a failed decryption "MUST NOT be treated as an error in the
* bag". What this class reads is a shard handed over directly: quoted in a
* note, or fetched by id.
*
* **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its
* `pubkey` is a claim and a client MUST NOT present it as verified authorship.
* Placement is attributable to the bag's author; authorship of an item's
* content only to the item's own pubkey, and only when the item is signed.
*/
@Immutable
class SnoShardEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun shard(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette)
fun shardOrNull(fetchedPalette: SnoPalette? = null): SnoPayload? = shard(fetchedPalette).payloadOrNull()
/**
* The exact cyberspace coordinate this shard claims, if it carries one.
*
* §7.6: an item MAY carry a `C` tag, which lets a client render it at a
* point rather than somewhere in the region. Where a shard came out of a
* bag that coordinate MUST lie inside the region the bag is encrypted to;
* this class has no bag to check it against, so it only reports the claim.
*/
fun coordinate(): String? = tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1)
companion object {
const val KIND = 3330
}
}
@@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -651,6 +652,7 @@ class EventFactory {
GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
SnoObjectEvent.KIND -> SnoObjectEvent(id, pubKey, createdAt, tags, content, sig)
SnoAvatarEvent.KIND -> SnoAvatarEvent(id, pubKey, createdAt, tags, content, sig)
SnoShardEvent.KIND -> SnoShardEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,74 @@
/*
* 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.cyberspace.deck0003Sno
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A `kind 3330` shard reads in either encoding, and the rules of §1 still apply
* to the one the deck specifies.
*/
class SnoShardEventTest {
private fun shard(
content: String = "",
tags: Array<Array<String>> = emptyArray(),
) = SnoShardEvent("aa".repeat(32), "11".repeat(32), 0L, tags, content, "22".repeat(64))
private val payloadJson =
"""{"v":2,"name":"hidden","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}"""
@Test
fun aShardCarriesThePayloadInContent() {
val payload = shard(content = payloadJson).shardOrNull()
assertNotNull(payload)
assertEquals("hidden", payload.name)
assertEquals(2, payload.version)
assertEquals(4, payload.vertexCount)
assertEquals(1, payload.faceCount)
assertEquals(0xFFFF0000.toInt(), payload.colors[0])
}
@Test
fun aShardIsHeldToTheSameRulesAsAnObject() {
// The container changed; §1.9 did not. This is one format in two
// containers, not two ways of writing the format.
val result = shard(content = """{"v":2,"name":"x","unit":0,"mode":"wireframe","vertices":[],"colors":[],"faces":[]}""").shard()
assertTrue(result is SnoResult.Invalid)
assertEquals("4", result.rule)
}
@Test
fun anEmptyShardIsNotAShape() {
assertTrue(shard().shard() is SnoResult.Invalid)
}
@Test
fun theCoordinateIsReadButIsOnlyAClaim() {
val coordinate = "3d5ffa91f5c8c13d0bc82ecfe2e546020b3d51763333589829c9c3fdc24fe74a"
val withC = shard(content = payloadJson, tags = arrayOf(arrayOf("C", coordinate)))
assertEquals(coordinate, withC.coordinate())
assertNull(shard(content = payloadJson).coordinate())
}
}