diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md new file mode 100644 index 0000000000..8678b88e7a --- /dev/null +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -0,0 +1,900 @@ +# DECK-0003 (SNO — Simple Nostr Objects) — feasibility and implementation plan + +_Status: **built** on `claude/epic-ptolemy-48n37b`. The reader, the rasterizer, +the feed/thread rendering and kind-11333 avatars all ship; §6 records what +landed and what did not._ + +Source: +(Draft, last updated 2026-09-16, requires `CYBERSPACE_V2.md` `2026-03-16-h34-corrected`). +Two implementations to check against, and they do not fully agree (§2.6): +`decks/sno-reference.py` in the same repo — no dependencies, 25 rejection cases, runs +clean — and [`arkin0x/sno-core`](https://github.com/arkin0x/sno-core) v0.1.4, the MIT +TypeScript library that ONOSENDAI and the snocrash workshop both read objects with. +The surrounding specs (`CYBERSPACE_V2.md` §7.6, §8.6, §8.10, §9.4, §11 and the other +two DECKs) are covered in §2. + +## 1. What the spec defines + +A **Simple Nostr Object** is a small triangle mesh written as JSON, small enough to +live in one event's `content`. Vertices sit on an integer lattice (whole model units +plus 120ths), one palette index per vertex, optional triangles, one of three draw +modes. No textures, no materials, no normals, no animation, no external file. The +object *is* the event. + +| Thing | Value | +| ---- | ---- | +| Standalone object | `kind 33331`, addressable, one per `(pubkey, d)` | +| Object hidden in a Cyberspace bag | `kind 3330`, a `kind 33330` bag item | +| Avatar | `kind 11333`, payload in content (CYBERSPACE_V2 §8.10) | +| Palette published as its own event | any kind; `kind 3367` (color moments) is what carries them today | +| Model space | X right, Y up, +Z toward the viewer — glTF/three.js, right handed | +| Lattice | whole units + ticks (1/120 of a unit), exact rationals, never floats | +| Hard limits | 512 vertices, 1024 faces | +| `v` | `1` (Z negated on read, colors are literal `[r,g,b]` triples) or `2` (Z as written, colors are palette indices) | + +The payload fields: `v`, `name`, `unit`, `mode`, `vertices`, `colors`, `faces` +required; `extent`, `ticks`, `facecolors`, `palette`, `up`, `spin` optional; `type` +a legacy field that MUST be ignored and MUST NOT be rejected on. + +Three encodings are run-length: `ticks` (a negative `-N` means N vertices at +`[0,0,0]`), `facecolors` (a negative `-N` repeats the previous index N more times). +Both are "sign separates the two entry kinds", which works because an index is never +negative. + +§1.9 is the validation table — ten numbered rules, and `decks/sno-reference.py` +carries a rejection case per rule. That table is the test suite; port it verbatim. + +### Confirmed against the live network, not from memory + +Queried `cyberspace.nostr1.com`, `relay.damus.io`, `nos.lol`, `relay.primal.net` +on 2026-09-21 (nostr.band reset the connection): + +``` +kind 33331 : 7 unique events, 6 distinct authors +kind 3330 : 2 unique events +kind 11333 : 0 +kind 3367 : 8 sampled palettes +``` + +Running each 33331 payload through the reference validator: + +| Event id (first 8) | Author | Result | verts/faces | `unit` | `mode` | event bytes | name | +| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | +| `fbe6a776` | `24bd64d4` | **rejected** | 4/4 | 0 | solid | 726 | hello world | +| `4b05663f` | `1b71f696` | **rejected** | 4/4 | 0 | solid | 735 | second thought | +| `04df8e12` | `e8ed3798` | **rejected** | 18/24 | 0 | lines | 1907 | Ship | +| `89f510e1` | `dcf07605` | valid | 35/20 | 0 | solid | 1231 | Shard 4 copy | +| `47031e0f` | `7e5dde75` | valid | 5/5 | 0 | solid | 684 | Shard 2 | +| `d1299608` | `f7e15ec0` | valid | 118/94 | 0 | solid | 3248 | first object | +| `8fe392c2` | `e8ed3798` | valid | 12/18 | 12 | solid | 981 | Triforce | + +**Three of the seven objects on the network fail the spec's own validator.** All +three fail on the same rule (8b): they are `v: 2` payloads whose `colors` are still +`[r, g, b]` triples (`[1, 0.15, 0.15]`), and all three also carry `type: "shard"`. +This is exactly the transition §5 documents — `colors` changed meaning inside `v: 2` +on 2026-09-16 and the authors expected to reissue three objects. They have not, and +one of the three (`04df8e12`, "Ship") was published by an author who *also* has a +correctly-encoded object, so this is drift, not a single stale client. + +§5 gives a reader explicit permission here: "a reader MAY reject one, and a reader +that would rather be generous MAY read the triples literally the way §1.3 reads a +`v: 1` payload's". **Take the generous branch** — otherwise Amethyst renders 4 of the +7 objects that exist and shows an error for the rest. Decision D1 below. + +Real payloads also already use `mode: "lines"` and a non-zero `unit` (12), so neither +is a theoretical branch. + +**The two `kind 3330` events on relays are v1 leftovers, and the current ones are +unreachable.** Both live shards have **empty `content`** and carry their geometry in +*tags*: + +``` +C 3d5ffa91… (64-hex cyberspace coordinate) +Cd 0 +S 13346173940832169-15655245699645789-31273450162226572 +ms 273 +version 1 +vertices (empty) colors (empty) indices (empty) +position 251894327,617397300,844414457 +display solid nonce 0000000000000000 e cb5a78f7… +``` + +That tag-encoded form is v1's and the DECK does not describe it. The current client +does write the §1 JSON payload in `content` — `src/lib/hidden.ts` in ONOSENDAI is +explicit ("a shard, kind 3330 (v1's shard kind), geometry in the content"), so §3.2 +is accurate about what is being produced today. + +The reason to defer 3330 is better than a mismatch, and it is `CYBERSPACE_V2.md` +§7.6 rather than the DECK. A current shard is not a relay event at all: it is an item +inside a **bag**, a `kind 33330` event whose payload is AES-256-GCM ciphertext in an +`encrypted` tag, keyed by the `location_decryption_key` of a region at a height +(§7.2). Opening one means implementing the coordinate system (§2), the region key +derivation (§7.2) and discovery scanning (§7.4) — the Cyberspace protocol, not a +rendering feature. A reader without the key sees base64 and nothing else, and §7.6 +says a failed decryption "MUST NOT be treated as an error in the bag". Defer it — +decision D2. + +**Palette events match §1.3b exactly.** All sampled `kind 3367` events carry 4–6 +`c` tags of well-formed `#rrggbb` in document order, plus `layout` / `alt` / `client` +/ `name`, with an emoji in `content`. The spec's survey holds; the `c`-tag reader is +the only palette-event reader worth writing. + +### Spec warts to handle defensively + +1. **`type` is poison to reject on.** §1.1a is emphatic, and three live events carry + it. Ignore it. +2. **No hard bound on how far a vertex may lie from the origin** — §8 open question 3 + says so outright. "512 vertices at 2^50 units is valid under this text and will + produce a grid no renderer wants." §1.8 puts the 64-unit bound on publishers and + lets a reader "reject such a payload" or "repair it by growing the extent". Both + references repair; so do we (see §4.2d). What is left is the lattice's own limit: + a position is a total tick count in an Int. +3. **`extent` is repaired, never validated** (§1.8). Out of range → `8`, then grow to + contain the data. It is the one deliberately forgiving rule; a reader that rejects + on it is wrong. +4. **Winding order carries no meaning** (§1.4). Draw both sides, never cull. +5. **A palette reference is never load-bearing** (§1.3b). Render immediately with the + built-in; re-render if and when the referenced event arrives; never block, never + reject. A reference is pinned to the event id it names — do not follow the `e` + chain forward. +6. **Welding/equality must happen on the integer pair**, before any float conversion + (§1.2). 1/120 is not representable in binary floating point. +7. **`name` is truncated to 64 characters, not rejected** (§1.8). +8. **`spin` is validated even when `up` is absent** (§1.7) but ignored for rendering. + +## 2. The surrounding specs, and the software that already reads this format + +DECK-0003 does not stand alone. Reading the repository it lives in, and the two +clients that implement it, changes three things in this plan. + +### 2.1 What a DECK is, and which ones matter here + +`decks/README.md` defines a DECK (Design Extension and Compatibility Kit) as a +self-contained optional extension over `CYBERSPACE_V2.md`. A DECK MAY define new +kinds and tags; it MUST NOT change consensus-critical base rules. Three exist: +DECK-0001 (Hyperspace, Bitcoin block transit), DECK-0002 (Virtual Spawn, a game +mechanic), DECK-0003 (SNO). **Neither 0001 nor 0002 touches geometry, objects or +rendering** — grepped, zero hits. SNO is the only DECK Amethyst would implement. + +DECK-0003 on `master` is the current revision: I fetched every pull-request head in +the repo and none carries a newer DECK-0003 than `master` does. The only artifact of +note is that SNO was DECK-**0004** in PR #25, renumbered in the same round that +dropped the mandatory `type: "shard"` and turned colors into palette indices — which +is precisely the change the three legacy events on the network predate. + +### 2.2 `CYBERSPACE_V2.md` §7 — the bag, and what opening one would actually cost + +Summarized in §1 above and it is the strongest argument in this plan: a current shard +is an item inside an AES-256-GCM bag keyed to a region, not a fetchable event. + +**How a bag is found is not what §7.4 makes it look like, and this plan said it +wrong twice.** §7.4's discovery scanning — walk the aligned cubes around your own +coordinate, derive each key, ask the relay — is the *opportunistic* half, and §7.7 +says outright what it is worth on its own: "a bag is found only by intentionally deep +scanning and/or wandering. With no additional information, any given bag is equally +likely to be at any point in the full 2^256 coordinate space: an impossibly hardened +secret." + +The mechanism that actually finds things is §7.7's **hint and sweep**, and the +sentence that matters is this one: + +> The seeker's own position never enters this cost, because §7.1 makes looking and +> walking equivalent: a region key can be computed for any coordinate without +> traveling there. + +A hider publishes a `hint` tag naming an aligned box as coarse as they like; a seeker +sweeps it, deriving a key per candidate region and batching the `lookup_id`s into one +relay query. The price is `2^((Hx-h)+(Hy-h)+(Hz-h))` candidates and nothing else. At +the degenerate end, "three heights equal to `h` name the region itself: the hint is +then a destination the seeker can compute or walk to directly, not a search." + +So **a client with no avatar and no position could open a hinted bag.** The reason +this plan defers it is not that Amethyst has nowhere to stand — that was the wrong +reason, stated in D2 and in `SnoShardEvent`'s KDoc — it is the cost and the surface. + +**The cost, measured.** A region key is three Cantor axis roots at the bag's height, +each an `O(2^h)` fold of BigInts that double in width every level (§4.6). The spec +publishes its own figures in §7.7, one desktop core, per key: about 0.05 ms at +heights 0–4, 1.3 ms at h8, 30 ms at h12, 816 ms at h16. Measured here against the +same fold, per axis: 4 ms at h12, 92 ms at h16, 2.7 s at h20, 13 s at h22 with a +GMP-class bignum — which puts the spec's numbers between a GMP build and a +Karatsuba-only one, as it should. The growth is **~2.2x per height**, both in the +spec's own figures and in the measurement, and it is 2.2 rather than 2 because a +pairing doubles the leaves *and* the operand width. Extrapolating that ratio, the +spec's human-scale h34 is on the order of days per key and a 174 GB root, against +§9.3's stated 185 GB — which is why `cyberspace-core` caps at +`DEFAULT_MAX_COMPUTE_HEIGHT = 20` and throws above it, and why ONOSENDAI sells the +heights above a machine's own ceiling through a paid service. + +**What that means for us.** Nothing changes about D2, but the reason is now the right +one: opening bags means carrying §2's coordinates, §4's Cantor trees and §7.2's +derivation, plus a sweep whose budget is somebody else's puzzle difficulty. That is a +protocol stack with its own compute economics, and it is not a rendering feature. + +### 2.3 `CYBERSPACE_V2.md` §8.10 — the avatar, and a proof of work that is normative + +This is the one piece of surrounding spec that is directly implementable and that the +DECK only gestures at. `kind 11333` is **replaceable** (one avatar per identity), its +content is an SNO payload or empty, and the work it owes is computed from the payload: + +```python +AVATAR_FLOOR_BITS = 16; AVATAR_SIZE_BITS = 2; AVATAR_DETAIL_BITS = 3; AVATAR_DETAIL_FREE = 32 +reach = max(1.0, max(abs(v + t/120) for every vertex coordinate) * 2 ** payload.unit) +detail = max(AVATAR_DETAIL_FREE, len(payload.vertices) + len(payload.faces)) +work = ceil(16 + 2*log2(reach) + 3*log2(detail/32)) +``` + +An avatar is **paid** when its NIP-13 `nonce` tag's committed `target` is at least +`work`, **and** its id carries at least `target` leading zero bits. Both conditions, +because committing the target before mining is what stops a lucky id being claimed +against a lower bar. And then, normatively: *"a client MUST NOT draw an avatar event +that is not paid, or that carries content it cannot read; it draws its default avatar +for that identity instead."* + +For us that is a short, self-contained addition on top of step 1, and Quartz already +has `nip13Pow/` for the leading-zero-bit count — but **not** as a one-liner, and the +shortcut is wrong. `Event.pow()` is `PoWRankEvaluator.compute(id, tags.commitedPoW())`, +which returns `min(actualRank, committed)`, so `event.pow() >= avatarWork(payload)` +passes an event whose id is short of its own commitment: work 16, committed 30, id +carrying 20 gives `pow() == 20`, which clears 16 while failing §8.10's second +condition. The check has to stay two conditions, read separately — +`PoWTag.parseCommitment(...) >= avatarWork(payload)` **and** +`PoWRankEvaluator.calculatePowRankOf(id) >= committed` — with a missing `nonce` tag +meaning unpaid. It is also the only place in this +whole surface where a rendering decision is gated on verification rather than taste. +Note the kind history: `11333` **was** `kind 33331` with a `d` fixed at `"avatar"`, +and 33331 was then handed to standalone SNO objects. A reader should not be surprised +by an old 33331 whose `d` is literally `avatar`; none of the seven live ones is. + +### 2.4 §9.4 and §11 — the axis conventions, which confirm the Z story + +§11.1 fixes `+X_cs` screen-right, `+Y_cs` up, `+Z_cs` forward *toward the black sun*, +and §11.3 makes "facing the black sun" the canonical camera. That is why SNO `v: 1` +has +Z away from the viewer and `v: 2` has it toward: v1 was Cyberspace's own frame, +v2 is glTF's. §9.4's `X_cs = X_ecef, Y_cs = Z_ecef, Z_cs = Y_ecef` is the swap that +makes DECK §1.7's (east, up, north) frame right-handed. + +**Do not copy the sign convention from the reference client.** `sno-core`'s +`fromPayload` flips Z on `v: 2` and leaves `v: 1` alone — the exact opposite of what +this plan says — because ONOSENDAI's internal frame *is* Cyberspace's, so it converts +the published frame inward. Amethyst has no Cyberspace frame and renders in the glTF +convention, so for us `v: 2` is as-written and `v: 1` is negated. Both are correct; +the direction depends on the renderer's frame, and getting it backwards mirrors every +asymmetric object without any error. + +### 2.5 The implementations that already exist + +| Project | What it is | License | +| ---- | ---- | ---- | +| [`arkin0x/sno-core`](https://github.com/arkin0x/sno-core) | The TypeScript SNO library, v0.1.4. The reader both clients use | MIT *(per `package.json`; no LICENSE file committed)* | +| [`arkin0x/onosendai`](https://github.com/arkin0x/onosendai) | The 3D Cyberspace client (react-three-fiber). Active — last commit the same day I read it | **CC-BY-SA-4.0** | +| `arkin0x/snocrash` | The shard workshop, the modeling tool. On sno-core 0.1.4 | not checked | +| `arkin0x/cyberspace-cli` / `cyberspace-cli-js` | Reference protocol implementations (Python / TS), where `avatar.py` / `avatar.ts` and the PoW golden vectors live | not checked | + +**Licensing, per the repo's dependency rule.** Nothing here gets linked into an APK — +this is a Kotlin port of a specification. But the rule's spirit applies to copied +source: `sno-core` declares MIT and is safe to read and to mirror test vectors from, +though it commits no LICENSE file and someone should ask upstream for one before we +lean on it. **ONOSENDAI is CC-BY-SA-4.0 — share-alike — so do not copy code out of +it.** Write from DECK-0003 and check behavior against `sno-reference.py`. + +`sno-core` also contains a great deal the DECK does not specify, all of it the +authoring side: `stamps` (seven parametric shapes on the grid), `triangulate` (ear +clipping plus the Newell normal), `outline` (each shared edge drawn once for `lines` +mode), `orient` (winding faces outward so a lit bench can paint backfaces dark), +`clip` (a shard cropped to the region cube it is sealed to — a rendering rule that +exists nowhere in the DECK), `pose`, `scale`, `hsv`. None of it is needed to *read* an +object. All of it would be needed to build a workshop, which is D3's argument. + +### 2.5a Checked against the reference implementations, not only the spec + +§8.10 says its two reference implementations are "pinned to one set of golden +vectors", and the first pass built the avatar ladder from the section's +pseudocode instead — which is the same text both implementations read, so +agreement with it proved nothing about agreement with them. Closed since: +`tests/fixtures/avatar_work.json` from cyberspace-cli is now a test, and **all +eight vectors pass**. The two fixture sets (cyberspace-cli and cyberspace-core) +carry the same eight names with identical `reach` and `required` throughout, so +the "one set" claim holds and checking either checks both. + +The vector that earns its keep is **"half gibson, ticks"**: a vertex of +`[-1, -1, -1]` with ticks `[60, 60, 60]` is **-0.5** of a unit, not -1.5, +because the whole part is the floor and the ticks count up from it. An +implementation that takes the magnitude before adding the ticks prices that +avatar at three times its reach and passes every other vector in the set. + +`verify_avatar_work` in the Python reference also confirms the shape of the +payment check independently: committed ≥ required **and** zeros ≥ committed, as +two separate conditions with distinct failure reasons — which is what §5 of this +plan's own note about `Event.pow()` is about. + +**The Python reference is stale on the avatar kind, and we do not follow it.** + +| Implementation | Avatar kind | +| ---- | ---- | +| `CYBERSPACE_V2.md` §8.10 | **11333** | +| cyberspace-core (TS, 2026-09-14) | 11333 | +| ONOSENDAI (the client) | 11333 | +| cyberspace-cli (Python, 2026-09-07) | **33331** | + +The Python CLI predates the kind move by a week and still calls 33331 the avatar +kind, which DECK-0003 has since given to standalone objects. Run against the +current network it would refuse a conformant avatar as `not-an-avatar`, and +would treat one of the seven published `kind 33331` objects as an avatar and +demand proof of work of it. We follow the spec and the two current +implementations. + +Nothing else in the Python CLI overlaps this work: it implements no §1.9, no +palette and no mode — its only SNO surface is the avatar ladder. It does +implement the `kind 33330` bag (encrypt/decrypt), which is the part we +deliberately do not. + +### 2.6 The two reference implementations disagree, and I checked which way + +The DECK's conformance note says `sno-reference.py` and ONOSENDAI's TypeScript reader +"agree on all twenty cases". That is no longer true. Running both against the same +payloads: + +| Payload | `sno-reference.py` | `sno-core` v0.1.4 | +| ---- | ---- | ---- | +| `v: 2` with literal `[r,g,b]` triples | **rejected** (rule 8b) | **accepted**, deliberately | +| `type: "shard"` present | accepted, ignored | accepted, ignored | +| `name` missing entirely | **accepted** — no check at all | accepted, defaults to `"shard"` | +| `name` a number | **accepted** | accepted, defaults | +| a vertex at 1,000 units from origin | accepted, extent grows | accepted, extent grows | + +Run against the seven live objects rather than synthetic payloads, the gap is the +whole legacy cohort: **`sno-core` v0.1.4 reads 7 of 7; `sno-reference.py` reads 4 of +7.** (Measured — `sno-core` built from source at v0.1.4 and `fromPayload` called on +each event's content.) + +Two things follow. **D1 is settled upstream, not a judgement call.** sno-core's +commit says it plainly: version 2 shipped as two changes that did not land together — +the wire turned right-handed in ONOSENDAI #165 and color became an index two releases +later in #167 — so objects written in between declare `v: 2`, carry triples, and are +correct in every other respect *including their frame*. Refusing them "orphaned" real +work in both clients. The two forms cannot be confused because one is an array and +the other an integer. Amethyst should match sno-core: accept, read literally, keep +the declared version's frame (a v2-with-triples object is **not** flipped). + +And **`name` is required in §1.1's table and enforced by nobody.** Treat it as +optional with a fallback; rejecting on it would be stricter than every implementation +that exists, over a field that is decoration. + +## 3. Quartz — new code + +Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/`. +This follows the `concord/cord05Invites/` precedent for a spec family that is not a +NIP; `nipCC…`-style naming would be a lie about where the spec lives. + +``` +cyberspace/deck0003Sno/ +├── SnoObjectEvent.kt kind 33331, extends BaseAddressableEvent +├── SnoPayload.kt @Immutable parsed object (IntArrays, not Lists) +├── SnoParser.kt §1.9, the whole table, rejection returns null/Result +├── SnoMode.kt solid | points | lines +├── SnoPalette.kt resolution: built-in | name | nevent/naddr | inline +├── SnoBuiltInPalette.kt the 256 cyberspace-neon-256 colors as an IntArray +├── SnoPaletteEventReader.kt c-tags first, content-array fallback (§1.3b) +├── TagArrayExt.kt / TagArrayBuilderExt.kt +└── tags/ reuse NameTag (nip01Core), AltTag (nip31Alts), DTag +``` + +### 3.1 Reuse — do not re-implement + +| Need | Already exists | +| ---- | ---- | +| Addressable event base, `dTag()`, `address()` | `nip01Core/core/BaseAddressableEvent.kt` | +| Lenient third-party JSON field readers | `nip01Core/kotlinSerialization/LenientJson.kt` | +| `alt` tag (NIP-31) | `nip31Alts/` | +| `nevent` / `naddr` decoding for the palette reference | `nip19Bech32/` | +| Fetching the referenced palette event | `nip01Core/relay/client/accessories/` — `fetchFirst`. **Do not hand-roll a REQ loop** (CLAUDE.md) | +| Event construction/signing | `eventTemplate` + `TagArrayBuilder` | + +### 3.2 Parse with `JsonElement`, not a `@Serializable` data class + +`ticks` is a heterogeneous array (triples interleaved with negative ints), +`facecolors` likewise, and `colors` is `Int` in `v: 2` but `[Double, Double, Double]` +in `v: 1`. kotlinx's declarative path cannot express that without four custom +serializers. Parse `LenientJson.parseToJsonElement(content)` once and walk it with an +explicit validator that mirrors §1.9's ten rules one function each, so a rule id can +be quoted in the failure. `experimental/clink/kotlinSerialization/ClinkKSerializers.kt` +is the in-repo precedent for hand-written readers over foreign JSON. + +Store the result as primitive arrays, not `List>`: at the ceiling that is +512×3 + 1024×3 ints, and the repo's hot-path style is raw arrays plus the `fast*` +operators (CLAUDE.md). `SnoPayload` holds `IntArray wholeX/Y/Y…` or a single flat +`IntArray(3 * n)` — flat is cheaper and the rasterizer wants it flat anyway. + +Keep positions as the integer pair `(whole, tick)` on the payload. Expose +`fun position(i: Int, axis: Int): Pair` and convert to float **only** at +the rasterizer boundary, per §1.2. + +### 3.3 The built-in palette is a vendored artifact + +`cyberspace-neon-256` is normative (Appendix C) and 256 entries. The upstream source +of truth is `decks/sno-palette.json` (2,748 bytes) and its generator +`decks/sno-palette.mjs`. Vendor the values as a `const val` hex string or a packed +`IntArray` in `SnoBuiltInPalette.kt` with a comment naming the upstream file and its +`built` date (`2026-09-16`), and a test that checks a handful of documented anchors +(238 = `#ff0000`, 235 = `#00ff00`, 239 = `#0000ff`, 225 = `#ffffff`, 224 = `#000000`) +so a bad paste cannot ship silently. + +### 3.4 Tests — the table already exists + +Port `_rejections()` from `sno-reference.py` verbatim: 25 cases across rules 1, 2, 3, +4, 5, 6, 7, 8, 8a, 8b, 8c and 10, each a one-field mutation of the Appendix A +tetrahedron. Plus: + +- Appendix A validates, and `positions()[3] == (1, 2, 1)`. +- 40 ticks is exactly one third of a unit — asserted on the integer pair. +- `extent` repair: a vertex at 9 units grows the hint 8 → 9; `extent: 0` becomes 8. +- Absent `ticks` means every position is whole; `[-4]` means the same thing. +- `facecolors: [238, -3]` expands to four faces of 238; absent is **not** color 0. +- The seven live events above as committed fixtures — four parse strictly, three + parse under D1, zero crash. Under D1 all seven parse, which is the parity target: + `sno-core` reads all seven and `sno-reference.py` reads four. + +### 3.5 Registration points (each is a real integration, not a formality) + +| File | Change | +| ---- | ---- | +| `quartz/utils/EventFactory.kt` | `SnoObjectEvent.KIND -> SnoObjectEvent(...)` in the `when`, plus the import. (`factories[kind]` exists as a runtime map but every first-party kind is compiled in.) | +| `amethyst/model/LocalCache.kt` | add `is SnoObjectEvent,` to the addressable branch near `is GitRepositoryEvent,` — without it the addressable cache never holds the object and a quoted `naddr` stays empty | +| `EventFactory.isKnownKind` | free once the branch exists; it decides whether a repost of a 33331 renders at all | +| `amethyst/model/CacheSearch.kt` | only if objects should be searchable by name | + +## 4. Rendering — the part with a real decision in it + +Amethyst has no 3D anything today. Grepped: no OpenGL, no `drawVertices`, no +`Vertices`, no mesh code anywhere in `commonsUI/` or `amethyst/`. + +### 4.1 `Canvas.drawVertices` is common Compose API but unusable below API 29 + +Verified by pulling `androidx.compose.ui:ui-graphics-desktop:1.7.0` and running +`javap`: `Vertices`, `VertexMode` and +`Canvas.drawVertices(Vertices, BlendMode, Paint)` are present in the **desktop/skiko** +artifact, so this is `commonMain` API, not an Android extra. It would give +Gouraud-shaded triangles for free on Android and Desktop alike. + +The blocker is Android: `Canvas.drawVertices()` is hardware-accelerated **only from +API 29**, and `android-minSdk = "26"`. On Android 8.0–9.0 it silently does nothing +on a hardware canvas. A `drawVertices`-only renderer ships a blank box to those users. + +### 4.2 Recommended: a CPU rasterizer in `commons/`, on the blurhash rails + +`commons/` already owns exactly this shape of code — `blurhash/BlurHashDecoder.kt` +decodes to an `IntArray` of ARGB in `commonMain`, and +`blurhash/PlatformImage.kt` is an `expect class` with a +`PlatformImage.create(pixels, width, height)` companion and actuals in +`androidMain` (Bitmap), `jvmMain` (BufferedImage) and `iosMain`. `commonsUI/` +turns one into a Compose-renderable image via `service/image/CoilImageBridge.kt` +(`expect fun PlatformImage.toCoilImage(): Image`), and `BlurHashFetcher` + +`BKeyer` already wire a synthetic data class into Coil's memory cache. + +An SNO thumbnail is the same pipeline with a different decoder: + +``` +SnoPayload → SnoRasterizer.render(payload, palette, w, h, rotation): IntArray + → PlatformImage.create(...) (commons, exists) + → .toCoilImage() (commonsUI, exists) + → AsyncImage(model = SnoWrapper(eventId, rotation)) +``` + +Why the CPU path rather than `drawVertices`: + +- **It works on every target** — API 26 Android, Desktop, and iOS — with no + per-platform branch and no `expect fun supportsDrawVertices()`. +- **It gets a z-buffer.** `drawVertices` has none; painter's algorithm (sort + triangles by depth) breaks on interpenetrating triangles, which lattice-built + geometry will have the moment two objects are authored to share an edge. +- **It is unit-testable headless.** A `jvmTest` can rasterize a fixture and assert on + pixels — no device, no screenshot harness. +- **`facecolors` is trivial** (fill flat) and Gouraud is trivial (interpolate the + three resolved colors barycentrically), which §4 of the spec requires as the + default unlit reading. + +Cost: about 150 lines. Project 512 vertices, cull nothing (§1.4), scanline-fill 1024 +triangles into a 384×384 buffer with a `FloatArray` depth buffer. That is well under +a millisecond of real work on the JVM; the Android figure needs measuring, not +guessing, before the interactive viewer lands. + +### 4.2a Why not just draw it on a Canvas — measured + +The obvious objection to a CPU rasterizer is that Skia is right there. It is a +fair one and it was measured rather than argued, at 512px, on the same meshes: + +| mesh | CPU rasterizer | Skia `drawPath` | Skia `drawVertices` | +| ---- | ---- | ---- | ---- | +| 8v/12f | 10.52 ms | **6.27 ms** | 6.71 ms | +| 118v/94f | 6.35 ms | **4.31 ms** | 7.15 ms | +| 512v/1004f | 41.31 ms | **15.46 ms** | 30.11 ms | + +(Skia figures include whole-scene render overhead and are *software* skiko — on +Android a Canvas is GPU-accelerated and would be far better than this. The depth +sort was hoisted out of the timing loop, which flatters them further.) + +**So a Canvas is faster, and it is still the wrong trade here**, for two reasons +that cost correctness rather than speed: + +1. **A Canvas has no depth buffer.** Triangles have to be sorted back to front + every frame, which is exact for a convex mesh and wrong for interpenetrating + ones. Lattice geometry invites exactly that case: §1.2's whole promise is + that two objects authored to share an edge do share it. +2. **`drawPath` cannot interpolate a face's vertices.** A Path fill is one + colour, so every face would flatten to one — and two of the seven objects on + the network are smooth three-colour gradients. `drawVertices` keeps them, but + it is hardware-accelerated only from API 29 against a `minSdk` of 26, and on + skiko it measured no better than the CPU path for small meshes. + +And the speed is not needed. A real object costs **0.25 ms** at thumbnail size +and **3 ms** per viewer frame, off the composition thread; the 54 ms figure is an +adversarial payload that §1.8 permits and nobody authors. Trading away depth +correctness and per-vertex colour for a 2x on numbers that already fit is a bad +deal, and §4 is explicit that the same object must not look like two objects — +which is what an API-29 split would produce. + +If a real device ever shows this mattering, the move is `drawVertices` behind +the existing interface for API >= 29, accepting painter's-algorithm artifacts +there, driven by a measurement on hardware rather than one in a container. + +**The other lever, unspent:** fixed per-frame overhead is 0.62 ms at 512px — two +buffer allocations plus the projection, about a fifth of a real object's frame. +A caller-owned render target would recover most of it, and is only worth doing +if the viewer needs to be tighter; the thumbnail is Coil-cached and pays it once. + +### 4.2b The viewer is the point, and the first pass under-built it + +The framing above — thumbnail cached, viewer secondary — had it backwards. +These objects exist to be turned and looked at; the viewer *is* the feature, and +the first pass shipped it half-built and capped: + +- **It only rotated.** No zoom, no pan, on an object whose whole purpose is + being inspected from angles. One finger now turns it, two move and scale it, + a double tap resets it. +- **It rendered at under half resolution during that very interaction.** The + 512px cap was justified by a 48ms measurement — of the *adversarial* mesh, + taken *before* the rasterizer got 4x faster. Re-measured after: a real object + at full 1080px costs **4.86 ms**. The cap was protecting against a number that + no longer existed, at the cost of a soft image exactly where sharpness matters. + (Re-measured on a busier machine while §4.2d's work landed: 8.04 ms before that + commit's hoist and 6.81 ms after. These runs are minutes apart on a shared box, so + read the ratios rather than the absolutes; every figure in §4.2c–d comes from one + run and is comparable within it.) + +The cap is now adaptive rather than constant: 384px while a finger is down, +full resolution — scaled by how far the reader has zoomed — once it lifts. The +frames during a turn are the ones that must not stutter and the ones nobody +inspects; the frame you stop on is the one that must be sharp. That also handles +the adversarial object without a fixed ceiling: coarse while it moves, and it +pays its 177ms once when it settles. + +Zoom and pan do not re-raster at all — they are a `graphicsLayer` transform of +the frame already drawn, which is free per frame; only rotation changes which +faces point at the viewer. The re-raster on settle is what sharpens a deep zoom +rather than leaving it an upscaled bitmap. + +### 4.2c Measured against the author's own viewer + +Two of arkin0x's repositories came up as "do we mimic the viewer?" and they are +not the same thing: + +- **`arkin0x/cyberspace-viewer`** is *not* the SNO viewer. Its last commit is + 2023-08-25, three years before DECK-0003; it draws v1 "Constructs" — wireframe + lattices from a hard-coded `ConstructLineData.ts` — through + `@react-three/drei`'s `OrbitControls`. Grepping it for `shard`, `33331` or + `sno` finds nothing. It is not a comparison point. +- **`arkin0x/ONOSENDAI`** is. `src/scene/ShardMesh.tsx` is the author's current + renderer, on `sno-core` v0.1.4, and it draws a shard in two distinct ways. + +**The world drawing, which we already matched exactly.** +`meshBasicMaterial vertexColors side={DoubleSide} toneMapped={false}` — unlit, +per-vertex colours, two-sided, no tone curve — is §4's default reading and is +what the rasterizer and the feed thumbnail already did. Lines mode builds its +edges from the faces with each shared edge once (`sno-core/outline`), with a +single polyline through the vertices when there are no faces, which is also +what `drawEdges` already did. + +**Two things it does that we did not.** + +1. **`§1.5`'s "draw the vertices as points in every mode" was half-implemented.** + We drew them in `points` and `lines` mode and under 3px, but not over a + solid — the one case the sentence is actually about. The reference keeps two + point profiles (`scene/pointDisc.ts`) and now so do we: where the vertices + *are* the shape they are 0.3 units across, clamped to 1.6–10px; under a + solid or a wireframe they are 0.07 units, clamped to 0.7–2.4px and laid over + at 55% — a hint that they are there, never a second shape competing with the + faces. They read the depth buffer and do not write it, as the reference's + point material does, so a vertex behind a face stays hidden while two + overlapping dots both land. On an object whose faces carry their own colours + the dots take the face colour: the reference arrives there by splitting every + coloured face into three corners of its own before drawing, and §1.4a has in + any case already decided the vertex colours are not what that object shows. + +2. **Their bench is lit and ours was not.** ONOSENDAI's *world* is unlit under + its bloom; its *workshop bench* — the mode where you turn one shard over to + understand it — uses `meshLambertMaterial vertexColors flatShading + side={FrontSide}` plus a second mesh of the same triangles wound the other + way in grey, so an open shape shows its inside and a missing face is plain. + Our viewer is a bench that was using the world's shading. §4 permits exactly + this ("A client MAY light an object instead, and many will") on two + conditions — normals per face, flat, from the triangle's own vertices, and + never smoothed across a shared vertex — and both are met. + +**What lighting cost, and what it needed first.** Nothing on the wire says which +way round a face is: §1.4 makes winding meaningless and §4 draws both sides, so +an object whose triangles disagree is indistinguishable from one whose triangles +agree — until it is lit, at which point half a stamped block goes black. So +`SnoFaceWinding` ports `sno-core`'s `orient.ts` (MIT): faces sharing a clean +edge are made to agree, which groups them into patches, and each patch is then +turned as a whole by ray-casting its own normals against the rest of the object. +A face whose rays cross an odd number of faces *both* ways is buried inside a +join and is left undrawn. That pass is quadratic in the face count and is the +most expensive thing the feature does — **0.63 ms** for the largest real object +(94 faces), **27.6 ms** for an adversarial one at §1.8's ceiling (1004 faces) — +so it is computed once per object, off the composition thread, and held across a +turn. The per-frame cost is unchanged: a 512px frame of a real object is +**1.21 ms** flat and **1.62 ms** lit. + +Hoisting the single-colour case out of the pixel loop while adding the light +paid for most of that: a face whose three corners agree — a stamped block, or +any face carrying its own colour — now resolves its colour once for the whole +triangle instead of testing for equality at every pixel. A real object at 1080px +went 8.04 → 6.81 ms in the same run that added the shading. + +**Where we still differ, deliberately.** Their bench is a perspective camera on +`OrbitControls` (`fov 50`, dolly 0.6–60, screen-space pan); ours is orthographic +with object-rotation and a `graphicsLayer` zoom. That is a thumbnail-first +choice — no perspective distortion, no near plane to clip against, and a raster +that can be produced headless — and it is not worth revisiting for a reader who +is looking at someone else's object rather than building one. The light switch +defaults **off**, so the feed and a freshly opened viewer both show §4's unlit +reading and an object looks the same everywhere it is quoted; it is offered only +on a filled object, since points and wireframes look identical either way. + +### 4.2d Following the comparison through + +§4.2c ended with two gaps and a list of things ONOSENDAI does that we do not. +Five of those were ours to close, and closing the last one reversed a decision +this document had recorded twice. + +**`unit` was never on screen.** The plan said to surface it and the first pass +did not, so a card read "8 vertices · 12 faces" for an object at `unit: 0` and +for the same geometry at `unit: 40` — a molecule and a mountain. `unit` is the +only field separating them. `CyberspaceScale` turns the exponent into a length +using the two facts that make it one: §1.6 says a model unit is `2^unit` base +units, and `CYBERSPACE_V2.md` §9.2 fixes the base unit, the gibson, at `2^-33` +metres. The measure table and the three-significant-figure rounding are +`sno-core/scale.ts`'s, and the test checks every value `unit` may take, 0 to 84, +against that file run over the same range — including `unit: 28`, which is +3.125 cm exactly and the one entry where a language's default rounding shows +(ECMAScript's `toPrecision` takes the larger; `roundToLong` agrees). + +**An object naming an external palette was drawn in the wrong colours.** +`SnoPaletteEventReader` and `SnoParser`'s `fetchedPalette` existed from the +start and nothing ever called them with anything, so every §1.3a reference +resolved to the built-in 256 in silence. ONOSENDAI does not fetch either — +`resolvePalette` has zero call sites there — but Amethyst already has the relay +plumbing, so `WithSnoPalette` loads the named event by id (§1.3a pins a +reference to the event it names and forbids following the `e` chain forward) +and re-parses. It matters more than it sounds: every palette on the network +today is a `kind 3367` colour moment of three to six colours, so an object built +against one and drawn against 256 built-in entries is not slightly off, it is a +different object. The re-parse can also *fail* where the first succeeded — index +238 is fine against 256 entries and out of range against five — and §1.3 makes +that a defect in the payload rather than something to clamp, which is what the +reference does too. + +That fetch exposed a real bug in the thumbnail cache. Coil keyed on +`sno:::`, on the reasoning that an addressable object +republished under the same `d` arrives as a new id — true of geometry, false of +colour. One event id now legitimately produces two pictures a second apart, so +the key carries a hash of the resolved colours; without it the first paint would +have been served from memory forever and the fetch would have bought nothing. + +**The default avatar drew nothing.** §8.10 makes an empty `kind 11333` the +default avatar — no geometry, no work owed, what everyone is before they adopt a +shape — and drawing nothing for it is correct and unhelpful: the note vanishes +out of the feed, which reads as a fault. It now gets the wireframe icosahedron +the reference puts in its place, built as an ordinary payload so it goes through +the same rasterizer and needs no second drawing path. Its face list is derived, +not remembered, because a hand-written icosahedron comes out looking almost +right; the test asserts twelve corners, thirty edges, twenty faces, every corner +on five and every edge on two, and it caught the hand-written one. + +**A shard's `C` tag was parsed and dropped.** §7.6 lets an item carry its exact +coordinate so a client can "render it at a point rather than somewhere in the +region". Amethyst has no region to render it in, but the tag still carries one +thing a reader understands unaided: the plane (§2.4), which says whether the +shard was hidden at a place on Earth or at one with no physical counterpart. +That is bit 0 of the 256-bit coordinate and it is where the decoding stops, on +purpose. Pulling X, Y and Z out is §2.3 plus an 85-bit decimal printer, and what +it would put on screen is three twenty-six-digit integers that tell a reader +with no world exactly nothing. The reading that *would* mean something — the GPS +position under a dataspace coordinate — is §9.7: 96-digit decimal arithmetic +with a hand-rolled deterministic trig series, carried so every client agrees on +where a place on Earth is. Amethyst has no Earth to agree about. + +**The ±64-unit rejection is gone, and should never have shipped.** §1.8 states +the position bound as an obligation on publishers and gives a reader a choice in +as many words: "A reader MAY reject such a payload and MAY instead repair it by +growing the extent." Both references repair. The argument recorded here for +rejecting — that we draw strangers' events in a feed — was a defence that was +never load-bearing: a far-off vertex costs the rasterizer nothing, because the +projection fits whatever it is handed to the frame, and the cost that does scale +is in the vertex and face counts, which §1.8 bounds and rule 3 enforces. What +was real was arithmetic: `whole * 120` overflows an Int, and `abs(Int.MIN_VALUE)` +is its own negative, so a coordinate could be silently rewritten to the origin. +That is now bounded where it actually lives — the whole is read as a Long, the +product is checked against `Int.MAX_VALUE` ticks, and past that there is no +coordinate to repair, only one that would wrap. An object at 65 units is drawn, +and rule 9 grows the extent past `MAX_EXTENT` to hold it, exactly as +`neededExtent` does. + +### 4.3 Two surfaces, and only one of them needs to be fast + +| Surface | Renderer | Cache | +| ---- | ---- | ---- | +| Feed / thread thumbnail — a static 3/4 view, no interaction | CPU raster once at a fixed rotation | Coil memory cache keyed by event id, exactly like `BlurHashFetcher`'s `BKeyer` | +| Full-screen viewer — drag to rotate, pinch to zoom | CPU raster per frame at reduced resolution while dragging; `drawVertices` on API ≥ 29 / skiko is a later optimization behind one interface, not a prerequisite | none | + +Shading follows §4: unlit by default, both sides drawn, flat fill for a face carrying +`facecolors`, barycentric interpolation otherwise, **never** averaged smooth normals, +never invented geometry. A client SHOULD also draw the vertices as points in every +mode so a tiny object stays visible — cheap, do it. + +`v: 1` negates Z on read (§2). Do it in the parser, so nothing downstream knows there +are two versions. + +`unit`, `up` and `spin` have no meaning in a client with no world frame. Parse and +validate them (rule 10 rejects a bad `spin` whether or not `up` is present), surface +`unit` as a caption ("1 unit = 2^12 gibsons"), and ignore them for layout. §1.6 says +so explicitly: an application with no natural base unit "SHOULD render an object at a +size that suits its context rather than refusing it." + +## 5. Security review items + +- **Enforce the limits before allocating** (§6). Count the arrays; never size a + buffer from a declared length. The format has no length fields for this reason. +- **Face indices are the crash** (§1.4). Every index `0 ≤ i < vertexCount` and the + three distinct, validated before a single triangle reaches the rasterizer. +- **The position bound the spec leaves open.** ~~Reject beyond ±64 units.~~ Reversed, + see §4.2d: §1.8 offers a reader reject *or* repair and both references repair, so + rejecting made Amethyst the only client that refused an object the rest of the + network drew. The reach a payload can claim is not a denial of service on its own — + the projection fits whatever it is given to the frame — and the one genuine hazard, + `whole * 120` overflowing an Int, is now checked as what it is: a representation + limit at `Int.MAX_VALUE` ticks, computed in a Long so the multiply cannot wrap. +- **A palette reference is a network fetch driven by a stranger's event.** Fetch it + through the existing relay accessories with the account's relay set, never from a + URL, never blocking a frame, and cap it at one fetch per object. +- **`kind 3330` items may be unsigned** (§6), in which case `pubkey` is a claim. If + 3330 is ever implemented, do not attribute. +- **Screen real estate is a resource** (§6). An object from a stranger draws in the + feed. Gate the thumbnail behind the same content-warning / follow-based rules the + feed already applies to images, rather than inventing a new one. + +## 6. Sequencing — what landed + +Built in this order, each step verified before the next: + +1. `quartz/…/cyberspace/deck0003Sno/` — payload, §1.9 validator, built-in + palette, palette-event reader, kind 33331, kind 11333 + the §8.10 work gate. + **50 tests**: the 25-case rejection table generated from `sno-reference.py`, + the avatar ladder computed from §8.10's pseudocode, all seven live objects as + fixtures. +2. `commons/…/sno/SnoRasterizer` — CPU rasterizer, **15 tests**, one per rule §4 + decides, plus the depth ordering a painter's algorithm gets wrong. Checked by + eye too: the object named Triforce comes out as a Triforce. +3. `commonsUI/…/sno/` — Coil fetcher + `SnoThumbnail` for the cached static + view, `SnoObjectViewer` drawing straight to a bitmap for the drag (each drag + 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` + `SnoShard.kt`, dispatched from + `NoteCompose` and `ThreadFeedView`, stored addressably/replaceably in + `LocalCache`, fetcher registered in both image loaders. + +**Decisions as taken:** D1 accept (parity with sno-core — all seven objects +read), D1b tolerate, D2 deferred, D3 read-only with the writer in quartz, D4 +inline quote + thread, D5 ~~clamp at ±64 units~~ repair by growing the extent, +as both references do (reversed in §4.2d). + +**Deliberately not wired:** kind 3330 bag items (D2), an authoring/modeling +tool, and replacing a user's NIP-01 profile picture with their cyberspace +avatar — that last one is a product decision about every feed row, not +plumbing. + +### The original plan + +1. **Quartz parse + validate + built-in palette + the 25-case table.** No UI. Lands + as a self-contained, fully tested module; `amy` can dump a parsed object for + interop checks. +2. **`SnoRasterizer` in `commons/`** with jvmTest pixel assertions on the four valid + live fixtures. Still no UI. +3. **`commonsUI` thumbnail** — Coil fetcher + keyer mirroring `BlurHashFetcher`, and + a `SnoThumbnail` composable. +4. **Amethyst wiring** — `ui/note/types/SnoObject.kt` with `RenderSnoObject`, a + branch in `NoteCompose.kt` (next to `is ChessGameEvent ->`) and in + `ThreadFeedView.kt` (next to `is Ps1SaveEvent`), plus the `LocalCache` addressable + branch. At this point a `nostr:naddr1…` to an object renders inline. +5. **Full-screen viewer** with drag-to-rotate, `mode` respected, palette-reference + resolution, and the `unit`/vertex-count caption. +6. **`kind 11333` avatars** — same payload, replaceable container, plus the §8.10 + proof-of-work gate on `nip13Pow/`. Small, self-contained, and the only part of + this surface where drawing is conditional on verification. Worth doing once + steps 1–2 exist, because avatars are the thing a Cyberspace user actually has. +7. **Optional later:** NIP-89 handler advertisement, an authoring/publishing path. + +Steps 1–2 are pure logic with no Android surface and no dependency on the rest; they +are where the risk is, and they are testable without a device. + +## 7. Decisions to make before step 1 + +- **D1 — legacy `v: 2` triples. Settled upstream; take it.** `sno-core` v0.1.4 + accepts them deliberately (§2.6), so this is parity with the reader both clients + run, not leniency we invent. Read the triples literally, keep the declared + version's frame — a `v: 2`-with-triples object is **not** Z-flipped — and put the + reasoning in the KDoc, because `sno-reference.py` still rejects and a future reader + 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. Settled twice: the container shipped first, the bag + followed.** `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. + + Opening the `kind 33330` bag around it was deferred here and is now built; see + `quartz/plans/2026-09-22-cyberspace-region-bags.md`. The deferral's *reason* was + restated correctly before it was lifted, and the restatement is what made the work + tractable: "we have no position in cyberspace" was never the obstacle — §7.7 is + explicit that a seeker's position never enters the cost of a sweep — the obstacle + was the compute and the protocol surface behind it. Both turned out to be + affordable in the corner anyone uses: a region key is 1.2 ms at height 8 against + the spec's own 1.3 ms, and a bag's own hint prices its search before a tree is + built. A bag whose hint prices it out of reach still says so and shows nothing, + which is §7.6 working as written: 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 + (`SnoObjectEvent.build`) in quartz anyway — it is twenty lines and the tests want + round-tripping. +- **D4 — where the object surfaces.** Inline quote + thread view is the minimum. + A dedicated feed of `kind 33331` is not obviously wanted; there are seven objects + on the network. +- **D5 — the ±64 unit clamp** (§5). **Reversed.** It shipped as a deliberate + divergence and it was the wrong call. Neither `sno-reference.py` nor `sno-core`'s + `fromPayload` bounds how far a vertex may lie from the origin — both grow the + extent, which is what §8's third open question admits and what §1.8 names as one + of a reader's two choices. sno-core even has a `validPoint(p, extent)` helper and + deliberately does not call it on the read path. "We render strangers' events in a + feed" argued for a defence that was never load-bearing: a far-off vertex costs the + rasterizer nothing, because the projection fits whatever it is handed to the frame + and the cost is in the vertex and face counts, which §1.8 *does* bound and rule 3 + *does* enforce. The only real hazard was arithmetic, and that is now bounded where + it actually lives. See §4.2d. + +## 8. Size, for the record + +Nothing here threatens a relay. The largest live object is 3,248 bytes for the whole +event. The spec's own ceiling is 18.9 KB for a typical 512-vertex/1024-face object +and 33.5 KB worst case with a color on every face, against strfry's stock 65,536-byte +`events.maxEventSize`. The built-in palette adds 3.3 KB only when carried inline, +which Amethyst never does as a reader. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 54174f1d6a..a557fa7734 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher import com.vitorpamplona.amethyst.commons.service.image.ThumbHashFetcher import com.vitorpamplona.amethyst.commons.service.image.readAuthAware import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader +import com.vitorpamplona.amethyst.commons.sno.SnoFetcher import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.quartz.utils.Log @@ -107,11 +108,13 @@ class ImageLoaderSetup { add(VideoFrameDecoder.Factory()) add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) + add(SnoFetcher.Factory) add(ThumbHashFetcher.Factory) add(BlossomFetcher.Factory(blossomServerResolver, callFactory, concurrentRequests, readAuth)) add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope, concurrentRequests, readAuth)) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) + add(SnoFetcher.SKeyer) add(ThumbHashFetcher.TKeyer) add(ProfilePictureFetcher.BKeyer) add(OkHttpFactory(callFactory, concurrentRequests, readAuth)) 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 06be1c0b45..20c88f6e1c 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 @@ -177,6 +177,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderCitation import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity +import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating import com.vitorpamplona.amethyst.ui.note.types.RenderExternalReaction @@ -235,6 +236,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport 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 @@ -281,6 +285,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDi import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +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 @@ -1412,6 +1420,22 @@ private fun RenderNoteRow( ) } + is SnoObjectEvent -> { + RenderSnoObject(baseNote, accountViewModel) + } + + is SnoAvatarEvent -> { + RenderSnoAvatar(baseNote, accountViewModel) + } + + is SnoShardEvent -> { + RenderSnoShard(baseNote, accountViewModel) + } + + is CyberspaceBagEvent -> { + RenderCyberspaceBag(baseNote, accountViewModel) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt new file mode 100644 index 0000000000..6249d38fed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt @@ -0,0 +1,340 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweep +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepQuote +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepState +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_box +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_cancel +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_damaged +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_destination +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped_many +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_empty +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_measuring +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_no_hint +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_not_found +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_opaque +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_out_of_reach +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_progress +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_search +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_title +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_hours +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_minutes +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unknown_kind +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unsigned +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagItem +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** + * Entry for a region bag (`CYBERSPACE_V2.md` §7.6, kind 33330) — content + * somebody hid at a place, addressed by a hash of a hash. + * + * The card's job is to say what the search costs and then not start it. §7.7's + * hint is the hider's difficulty knob, which makes the price part of the + * content: a box of 4,096 regions is a minute of a phone, and a box of 2^30 is + * a day of it, chosen by a stranger. So the gap is read off two tags while the + * row composes — arithmetic, no trees — and the card either offers a button or + * says the box is out of reach. + * + * **Every sweep is a tap, including a gap-0 hint.** §7.7 calls that one "a + * destination the seeker can compute or walk to directly, not a search", and it + * costs about as long as a frame. It still waits for the tap, because a reader + * who learns that some bags open themselves has learned an expectation a + * hostile bag can hide inside. + * + * What comes out renders through the cards that already exist: a `3330` shard + * through [SnoObjectCard], a `kind 1` as its text, anything else named and + * skipped. §7.6 decides the attribution and it is not the obvious one — + * *placement* belongs to the bag's author, *authorship* only to a signed item's + * own pubkey — so an unsigned item says so under it rather than borrowing + * either name. + */ +@Composable +fun RenderCyberspaceBag( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val noteEvent = baseNote.event as? CyberspaceBagEvent ?: return + val quote = remember(noteEvent) { BagSweep.quote(noteEvent) } + + var state by remember(noteEvent) { mutableStateOf(null) } + var job by remember(noteEvent) { mutableStateOf(null) } + + // Scrolling the row away is a cancel: this scope dies with the composition, + // and the sweep is a cold flow, so the work stops at the next candidate. + // Nothing here should outlive the card that asked for it. + val scope = rememberCoroutineScope() + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = stringResource(Res.string.cyberspace_bag_title), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = + when (quote) { + is BagSweepQuote.Hidden -> stringResource(Res.string.cyberspace_bag_no_hint) + is BagSweepQuote.OutOfReach -> stringResource(Res.string.cyberspace_bag_out_of_reach, quote.gapBits) + is BagSweepQuote.Searchable -> + if (quote.destination) { + stringResource(Res.string.cyberspace_bag_destination) + } else { + stringResource(Res.string.cyberspace_bag_box, quote.candidates.toString()) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + + if (quote is BagSweepQuote.Searchable) { + Sweeper( + state = state, + onStart = { + job = + scope.launch { + BagSweep + .sweep(noteEvent) + .flowOn(Dispatchers.Default) + .collect { state = it } + } + }, + onCancel = { + job?.cancel() + job = null + state = null + }, + ) + } + + when (val settled = state) { + is BagSweepState.Opened -> Contents(settled.contents, accountViewModel) + is BagSweepState.NotFound -> Footnote(stringResource(Res.string.cyberspace_bag_not_found)) + is BagSweepState.OutOfReach -> Footnote(outOfReach(settled.estimateMillis)) + else -> {} + } + } +} + +/** The button, or the progress and the stop that replace it while a sweep runs. */ +@Composable +private fun Sweeper( + state: BagSweepState?, + onStart: () -> Unit, + onCancel: () -> Unit, +) { + when (state) { + null -> TextButton(onClick = onStart) { Text(stringResource(Res.string.cyberspace_bag_search)) } + + is BagSweepState.Measuring -> Progress(null, stringResource(Res.string.cyberspace_bag_measuring), onCancel) + + is BagSweepState.Running -> + Progress( + fraction = if (state.candidates > 0) state.examined.toFloat() / state.candidates else null, + label = stringResource(Res.string.cyberspace_bag_progress, state.examined.toString(), state.candidates.toString()), + onCancel = onCancel, + ) + + // Settled. A cancel resets the state to null and the button comes back; + // an outcome does not, because sweeping the same box again derives the + // same keys and reaches the same answer. + else -> {} + } +} + +@Composable +private fun Progress( + fraction: Float?, + label: String, + onCancel: () -> Unit, +) { + Row(Modifier.fillMaxWidth().padding(top = 6.dp)) { + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.height(4.dp)) + if (fraction == null) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth()) + } + } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onCancel) { Text(stringResource(Res.string.cyberspace_bag_cancel)) } + } +} + +/** + * What the bag held (§7.6's two plaintext shapes). + * + * A null [contents] is the one case that is genuinely a broken bag: the + * `lookup_id` matched, and §7.2 makes that a hash of the very key we tried, so + * a GCM tag that then fails is damage rather than a wrong reader. + */ +@Composable +private fun Contents( + contents: CyberspaceBagContents?, + accountViewModel: AccountViewModel, +) { + when (contents) { + null -> Footnote(stringResource(Res.string.cyberspace_bag_damaged)) + + is CyberspaceBagContents.Opaque -> + Footnote(asText(contents.bytes) ?: stringResource(Res.string.cyberspace_bag_opaque, contents.bytes.size)) + + is CyberspaceBagContents.Items -> { + if (contents.items.isEmpty() && contents.dropped == 0) { + Footnote(stringResource(Res.string.cyberspace_bag_empty)) + } + contents.items.forEach { item -> + Spacer(Modifier.height(6.dp)) + Item(item, accountViewModel) + } + // §7.6: a forged item costs itself and nothing else, but it is + // still worth saying that something was thrown away. + if (contents.dropped > 0) { + Spacer(Modifier.height(6.dp)) + Footnote( + if (contents.dropped == 1) { + stringResource(Res.string.cyberspace_bag_dropped, contents.dropped) + } else { + stringResource(Res.string.cyberspace_bag_dropped_many, contents.dropped) + }, + ) + } + } + } +} + +/** + * One item, through whichever card already draws its kind. + * + * The `3330` case is what a bag is usually for: DECK-0003 §3.2's shard, whose + * payload is the same format a standalone object carries, which is why + * [RenderSnoShard] and this end at the same card. An item that came out of a + * bag is never in [com.vitorpamplona.amethyst.commons.model.cache.LocalCache] — + * it was ciphertext a moment ago — so there is no `Note` to hand the usual + * renderers, and these draw from the event directly. + */ +@Composable +private fun Item( + item: CyberspaceBagItem, + accountViewModel: AccountViewModel, +) { + when (val event = item.event) { + is SnoShardEvent -> { + val first = remember(event) { event.shard() } + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + when (val parsed = remember(event, palette) { if (palette == null) first else event.shard(palette) }) { + is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) + is SnoResult.Valid -> SnoObjectCard(payload = parsed.payload, eventId = event.id) + } + } + } + + is TextNoteEvent -> Text(text = event.content, style = MaterialTheme.typography.bodyMedium) + + // §7.6: "a reader that does not understand an item's kind skips it and + // renders the rest". Named rather than silent, so the count adds up. + else -> Footnote(stringResource(Res.string.cyberspace_bag_unknown_kind, event.kind)) + } + + // §7.6: an item without a `sig` is allowed, "its `pubkey` is then a claim, + // and readers MUST NOT present it as verified". Placement still belongs to + // the bag's author either way, which is why this only disclaims authorship. + if (!item.verified) Footnote(stringResource(Res.string.cyberspace_bag_unsigned)) +} + +/** A small grey line: this card's only other voice. */ +@Composable +private fun Footnote(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +/** The refusal, in whichever unit does not read as a wall of minutes. */ +@Composable +private fun outOfReach(estimateMillis: Long): String { + val minutes = estimateMillis / 60_000L + return if (minutes >= MINUTES_PER_HOUR) { + stringResource(Res.string.cyberspace_bag_too_slow_hours, (minutes / MINUTES_PER_HOUR).toInt()) + } else { + stringResource(Res.string.cyberspace_bag_too_slow_minutes, minutes.toInt()) + } +} + +/** + * §7.6's other shape as text, or null when it is not UTF-8 — "a text note or a + * file", and a file should not be shown as a wall of replacement characters. + */ +private fun asText(bytes: ByteArray): String? { + val text = bytes.decodeToString() + return if (text.encodeToByteArray().contentEquals(bytes)) text else null +} + +private const val MINUTES_PER_HOUR = 60L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt new file mode 100644 index 0000000000..9bfc6b2ac4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt @@ -0,0 +1,103 @@ +/* + * 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.SnoAvatarCard +import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarDefaultCard +import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarUnpaidCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload + +private val VIEWER_HEIGHT = 360.dp + +/** + * Entry for a cyberspace avatar (kind 11333). + * + * §8.10 gates the drawing rather than the parsing: an avatar that has not paid + * for its reach and its detail, or whose content cannot be read, MUST NOT be + * drawn. + * + * Empty content is the default avatar: it owes no work, and it is what every + * identity is until it adopts a shape. Drawing nothing for it is correct and + * unhelpful — the note disappears out of the feed, which reads as a fault — so + * it gets the wireframe icosahedron the reference puts in its place. + */ +@Composable +fun RenderSnoAvatar( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val noteEvent = baseNote.event as? SnoAvatarEvent ?: return + if (noteEvent.isDefaultAvatar()) { + SnoAvatarDefaultCard(noteEvent.id) + return + } + + // One parse: pricing an avatar reads its payload, and payment() hands that + // back, so nothing here parses the same content twice on the way to a frame. + // The price cannot change with the palette — §8.10 charges for reach and + // detail and never for colour — but whether the payload reads at all can, + // so the verdict is taken again with whichever palette arrives. + val first = remember(noteEvent) { noteEvent.payment() } + + WithSnoPalette(first.payload?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + val payment = remember(noteEvent, palette) { if (palette == null) first else noteEvent.payment(palette) } + val shape: SnoPayload? = if (payment.ok) payment.payload else null + + if (shape == null) { + SnoAvatarUnpaidCard() + } else { + var turning by remember(noteEvent) { mutableStateOf(false) } + + SnoAvatarCard( + payload = shape, + eventId = noteEvent.id, + name = noteEvent.nameTag(), + onClick = { turning = true }, + ) + + if (turning) { + Dialog(onDismissRequest = { turning = false }) { + SnoObjectViewer( + payload = shape, + eventId = noteEvent.id, + contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null }, + modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt new file mode 100644 index 0000000000..638579782c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt @@ -0,0 +1,96 @@ +/* + * 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.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult + +private val VIEWER_HEIGHT = 360.dp + +/** + * Entry for a Simple Nostr Object (DECK-0003 kind 33331). + * + * The geometry travels in the event's content, so the object itself is drawn + * from what the note already carries. The one thing that can be elsewhere is + * its palette: §1.3a lets `colors` index a palette another event holds, and + * [WithSnoPalette] fetches that one. Until it arrives the object is drawn + * against the built-in, which is what §1.3b says an unresolved reference means. + * + * A payload that fails §1.9 is not drawn — the deck requires that — and the + * rule it broke is shown instead of nothing. + */ +@Composable +fun RenderSnoObject( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val noteEvent = baseNote.event as? SnoObjectEvent ?: return + // Parsed once against the built-in to learn which palette it wants, then + // again with that palette once it is in hand. The second parse can fail + // where the first did not — an index of 238 is fine against 256 entries and + // out of range against a five-colour moment — and that is the object being + // wrong rather than the palette. + val first = remember(noteEvent) { noteEvent.sno() } + + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.sno(palette) } + + 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), + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt new file mode 100644 index 0000000000..7072fa516f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt @@ -0,0 +1,101 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPalette +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteEventReader +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote + +/** + * The 256 colours an object's palette indices name, fetched when the object + * names them in another event (DECK-0003 §1.3a). + * + * `colors` is an index into a palette and the payload says which one: absent + * means the built-in, a list means the object carries its own, and an `nevent` + * or `naddr` means somebody else's event holds it. Until that event is in hand, + * an object that names one is drawn against the built-in — §1.3b is explicit + * that a reference which has not resolved "counts as a failed fetch, which + * means the built-in", never a refusal to draw. So this hands back null first + * and the palette after, and the object appears immediately and repaints once. + * + * **Pinned to the event named, and never to a newer one.** §1.3a: "A reader + * MUST NOT follow the `e` chain forward to a newer version, and MUST render the + * object with the event the object names." That is why an `nevent` is loaded by + * its own id. An `naddr` is accepted too, for a palette somebody publishes as an + * addressable event of their own, and there the address *is* what was named. + * + * Fetching matters more than it sounds. Every palette on the network today is a + * `kind 3367` colour moment of three to six colours, so an object built against + * one and drawn against the built-in 256 is not slightly off — it is a + * different object, in colours its author never chose. It can also stop being + * drawable: an index of 238 is fine against 256 entries and out of range + * against five, and §1.3 makes that a defect in the payload rather than + * something to clamp. Both are the format working as written. + */ +@Composable +fun WithSnoPalette( + ref: SnoPaletteRef, + accountViewModel: AccountViewModel, + content: @Composable (SnoPalette?) -> Unit, +) { + if (ref !is SnoPaletteRef.Event) { + // Inline and built-in are already resolved inside the payload; there is + // nothing to wait for and nothing to fetch. + content(null) + return + } + + when (val entity = remember(ref.bech32) { Nip19Parser.uriToRoute(ref.bech32)?.entity }) { + is NEvent -> LoadPaletteNote(entity.hex, accountViewModel, content) + is NNote -> LoadPaletteNote(entity.hex, accountViewModel, content) + is NAddress -> + LoadAddressableNote(entity.address(), accountViewModel) { note -> + content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } }) + } + // A reference the parser accepted as well-formed but that names nothing + // this client can look up. The built-in, as an unresolved one always is. + else -> content(null) + } +} + +@Composable +private fun LoadPaletteNote( + hex: String, + accountViewModel: AccountViewModel, + content: @Composable (SnoPalette?) -> Unit, +) { + LoadNote(hex, accountViewModel) { note -> + // The kind is deliberately not checked: §1.3b says this format "does + // not define a palette kind and does not want one", and a reader that + // accepts the shape reads whatever convention wins. An event that is + // not one reads as null, which is the built-in. + content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt new file mode 100644 index 0000000000..c67fdd61e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt @@ -0,0 +1,136 @@ +/* + * 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.resources.Res +import com.vitorpamplona.amethyst.commons.resources.sno_shard_dataspace +import com.vitorpamplona.amethyst.commons.resources.sno_shard_ideaspace +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.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate +import com.vitorpamplona.quartz.cyberspace.CyberspacePlane +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent +import org.jetbrains.compose.resources.stringResource + +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. + * + * This is the shard that arrived **on its own**: quoted in a note, or fetched + * by id. One still inside its `kind 33330` bag is ciphertext until that + * region's key is derived, and deriving it belongs to [RenderCyberspaceBag], + * which sweeps the box the bag's hint names (§7.7) and renders what comes out + * through this same card. So a sealed shard reaching here is one nobody has + * opened, not one nobody can. + */ +@Composable +fun RenderSnoShard( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val noteEvent = baseNote.event as? SnoShardEvent ?: return + + // A shard whose geometry is still inside its bag is not a broken payload + // and §7.6 says so outright, so there is nothing to report and nothing to + // draw. Both 3330s reachable on a relay today are this. + if (noteEvent.isSealed()) return + + val first = remember(noteEvent) { noteEvent.shard() } + val place = placeOf(noteEvent) + + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.shard(palette) } + + 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 }, + footnote = place, + ) + + 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), + ) + } + } + } + } + } +} + +/** + * Where this shard says it is, in the little a client with no world can say. + * + * §7.6 lets an item carry a `C` tag holding its exact coordinate, "which lets a + * client render it at a point rather than somewhere in the region". Amethyst has + * no region and no point to render it at, but the tag still carries one thing a + * reader understands on its own: the plane, which says whether the shard was + * hidden at a place on Earth or at one with no physical counterpart (§2.4). The + * coordinate itself is abbreviated beside it — enough to tell two shards apart, + * and the whole of it is in the event for a client that can go there. See + * [CyberspaceCoordinate] for why the axes are not decoded. + * + * Null when there is no `C` tag, which §7.6 allows: such an item "is located no + * more precisely than the region". + */ +@Composable +private fun placeOf(noteEvent: SnoShardEvent): String? { + val coordinate = noteEvent.coordinate() ?: return null + val plane = CyberspaceCoordinate.planeOf(coordinate) ?: return null + val short = coordinate.take(SHORT_COORDINATE) + "\u2026" + coordinate.takeLast(SHORT_COORDINATE) + return when (plane) { + CyberspacePlane.DATASPACE -> stringResource(Res.string.sno_shard_dataspace, short) + CyberspacePlane.IDEASPACE -> stringResource(Res.string.sno_shard_ideaspace, short) + } +} + +/** How much of a 32-byte coordinate to show at each end. */ +private const val SHORT_COORDINATE = 6 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 34ff74e75b..54c43540f4 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 @@ -205,6 +205,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderCitation +import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource @@ -250,6 +251,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayReview import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation 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 @@ -269,6 +273,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +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 @@ -1069,6 +1077,14 @@ private fun FullBleedNoteCompose( RenderBirdex(baseNote) } else if (noteEvent is BirdDetectionEvent) { RenderBirdDetection(baseNote) + } else if (noteEvent is SnoObjectEvent) { + RenderSnoObject(baseNote, accountViewModel) + } else if (noteEvent is SnoAvatarEvent) { + RenderSnoAvatar(baseNote, accountViewModel) + } else if (noteEvent is SnoShardEvent) { + RenderSnoShard(baseNote, accountViewModel) + } else if (noteEvent is CyberspaceBagEvent) { + RenderCyberspaceBag(baseNote, accountViewModel) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/cli/README.md b/cli/README.md index 8556c5cf3d..d031074438 100644 --- a/cli/README.md +++ b/cli/README.md @@ -229,6 +229,7 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | | `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. | | `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. | +| `amy sno ` | Simple Nostr Objects (DECK-0003): validate a payload against §1.9, price the proof of work an avatar owes for it (§8.10), or check whether a kind:11333 avatar event has paid. Local and accountless; every rule lives in quartz. | | `amy namecoin resolve IDENT [--server HOST:PORT[:tcp][,…]] [--timeout SECS]` | Resolve a Namecoin identifier (`.bit`, `d/`, `id/`, `alice@example.bit`) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS (`:tcp` for plaintext), no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. | | `amy namecoin servers` | Print the default ElectrumX server list (host, port, TLS flag). | | `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 74645af784..09b80c2072 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -76,6 +76,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | | Live activities / chess (NIP-53 / NIP-64) | 🆕 | | +| Simple Nostr Objects (DECK-0003) | ✅ read-only | `SnoCommands` — `sno parse` (§1.9 verdict + the rule a refusal broke), `sno work` (the §8.10 proof of work an avatar owes), `sno verify` (whether a kind:11333 avatar has paid). Thin over quartz `cyberspace/deck0003Sno/`. Conformance harness at `cli/tests/sno/` diffs all three against the cyberspace project's own `sno-reference.py` and `cyberspace-cli`. Publishing/authoring 🆕. | | Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. | | NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | | NIP-47 Wallet Connect | 🆕 | | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 3eec899f21..6d7207d0f9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.cli.commands.BuzzCommands import com.vitorpamplona.amethyst.cli.commands.ConcordCommands import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand +import com.vitorpamplona.amethyst.cli.commands.CyberspaceCommands import com.vitorpamplona.amethyst.cli.commands.DebitCommands import com.vitorpamplona.amethyst.cli.commands.DecodeCommand import com.vitorpamplona.amethyst.cli.commands.DecryptCommand @@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.ServeCommand +import com.vitorpamplona.amethyst.cli.commands.SnoCommands import com.vitorpamplona.amethyst.cli.commands.StatusCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.StreamCommands @@ -242,6 +244,8 @@ private suspend fun dispatch(argv: Array): Int { "filter" -> return FilterCommand.run(tail) "nip" -> return NipCommand.run(tail) "kind" -> return KindCommand.run(tail) + "sno" -> return SnoCommands.dispatch(tail) + "cyberspace" -> return CyberspaceCommands.dispatch(tail) "namecoin" -> return NamecoinCommand.dispatch(tail) } @@ -512,6 +516,9 @@ private fun printUsage() { | nip N show a NIP (repo first, then a Nostr wiki/long-form fallback) | nip list fetch the NIP index (README) from the repo | kind N|NAME look up an event kind's label + NIP (number, or search by name) + | sno Simple Nostr Objects (DECK-0003): validate, price, verify an avatar + | cyberspace Cyberspace places (§2), region keys (§7.2), hint boxes + | (§7.7), and opening or sweeping for a bag (§7.6) | namecoin resolve IDENT resolve a Namecoin identifier (.bit, d/, id/, alice@x.bit) | [--server URL[,URL]] to a Nostr pubkey + relays via the Namecoin blockchain | [--timeout SECS] (no account, talks to ElectrumX over TLS) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt new file mode 100644 index 0000000000..cdfd3d34be --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt @@ -0,0 +1,459 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.cyberspace.CantorTree +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate +import com.vitorpamplona.quartz.cyberspace.CyberspaceHint +import com.vitorpamplona.quartz.cyberspace.CyberspacePlane +import com.vitorpamplona.quartz.cyberspace.RegionKey +import com.vitorpamplona.quartz.cyberspace.RegionKeyMaterial +import com.vitorpamplona.quartz.cyberspace.RegionSweep +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.io.encoding.Base64 + +/** + * `amy cyberspace …` — places and the keys they derive, local and accountless. + * + * Thin assembly only: §2's interleave, §4's Cantor trees and §7.2's derivation + * all live in quartz's `cyberspace/`. These verbs exist so the Kotlin + * implementation can be diffed against `cyberspace-cli` without a device, a + * relay or an account — the same arrangement `amy sno` has, and for the same + * reason. A region key is a consensus value: if ours differs from theirs by a + * byte, a bag they hid is one we cannot open, and that is a difference worth + * catching in a shell script rather than in a feed. + */ +object CyberspaceCommands { + val USAGE: String = + """ + |amy cyberspace — places and region keys (local, accountless) + | + | cyberspace coord COORD_HEX decode a coordinate: axes, plane, sectors (§2.2) + | cyberspace region COORD_HEX --height H the region key at that height (§7.2) + | cyberspace hint [EVENT|-] read a bag's hint and price its sweep (§7.7) + | cyberspace open [EVENT|-] --key HEX open a bag with a region key (§7.6) + | cyberspace sweep [EVENT|-] sweep a bag's hint box for its key (§7.7) + | + |COORD_HEX is 32 bytes of lowercase hex, as a `C` or `hint` tag carries it. + | + | --height H the aligned subtree height, 0..20 (default 0) + | --max-height H raise the refusal ceiling; the cost doubles and then + | some with every height, so this is deliberate work + | + |open: + | --key HEX the 32-byte region key (§7.2) + | --coord COORD_HEX derive the key from a coordinate instead of naming it + | --height H the height to derive at; defaults to the bag's `h` tag + | + |sweep: + | --max-gap G refuse a hint whose gap exceeds G bits (default 20). + | The gap is the exponent: 2^G region keys to derive. + | --limit N stop after N candidates, found or not + | --ids print every candidate's lookup_id, not only the match + """.trimMargin() + + suspend fun dispatch(tail: Array): Int = + route( + "cyberspace", + tail, + "cyberspace ", + mapOf( + "coord" to { rest -> coord(rest) }, + "region" to { rest -> region(rest) }, + "hint" to { rest -> hint(rest) }, + "open" to { rest -> open(rest) }, + "sweep" to { rest -> sweep(rest) }, + ), + USAGE, + ) + + /** + * Decode a coordinate. The axes are reported as decimal strings because an + * 85-bit value does not fit any JSON number a reader can trust. + */ + private fun coord(rest: Array): Int { + val args = Args(rest) + val hex = args.positional.firstOrNull() ?: return Output.error("bad_args", "cyberspace coord COORD_HEX") + args.rejectUnknown() + + val point = CyberspaceCoordinate.decode(hex) ?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex") + + Output.emit( + mapOf( + "coord" to hex, + "plane" to if (point.plane == CyberspacePlane.DATASPACE) "dataspace" else "ideaspace", + "x" to decimal(point.x.high, point.x.low), + "y" to decimal(point.y.high, point.y.low), + "z" to decimal(point.z.high, point.z.low), + "sector" to point.sector(), + ), + ) + return 0 + } + + /** + * The region key at a height: the same three fields `cyberspace-cli`'s + * `derive_region_key_material_for_height` returns, minus `region_n`, which + * is eleven kilobytes of hex by height 10 and is pinned exactly by the key + * that hashes it. + */ + private fun region(rest: Array): Int { + val args = Args(rest) + val hex = args.positional.firstOrNull() ?: return Output.error("bad_args", "cyberspace region COORD_HEX --height H") + val height = args.intFlag("height", 0) + val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT) + args.rejectUnknown() + + val point = CyberspaceCoordinate.decode(hex) ?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex") + if (height < 0) return Output.error("bad_args", "--height must be >= 0") + if (height > maxHeight) { + // The same refusal both references make, and for the same reason: + // one height further is twice the leaves and a root twice as wide. + return Output.error("too_big", "height $height exceeds max-height $maxHeight") + } + + val material = RegionKey.at(point, height, maxHeight) + Output.emit( + mapOf( + "coord" to hex, + "height" to height, + "region_bits" to material.regionN.bitLength, + "key" to material.decryptionKey.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }, + "lookup_id" to material.lookupId, + ), + ) + return 0 + } + + /** + * Read a bag's `hint` tag and price the search it describes (§7.7). + * + * A hint is the hider's difficulty knob, so the number that matters is the + * gap: `(Hx - h) + (Hy - h) + (Hz - h)`, the exponent of the candidate + * count. §7.7's own table reads in those terms — 12 is seconds, 24 is + * hours, 30 or more is "days to never" — and a client that means to offer a + * sweep has to know which of those it is offering *before* it starts. + * + * A malformed hint reports `hint: false` rather than an error, because + * §7.7 says a bad hint is an absent one and "never invalidates the bag". + */ + private fun hint(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + val event = + try { + Event.fromJson(json) + } catch (_: Exception) { + return Output.error("bad_event", "not a nostr event") + } + + // §8.6's `h` tag: the height of the region the content is keyed to. A + // hint is read against it, since a box smaller than the region it + // claims to hold is one of §7.7's malformed cases. + val bagHeight = + event.tags + .firstOrNull { it.size > 1 && it[0] == "h" } + ?.get(1) + ?.toIntOrNull() + val hint = CyberspaceHint.read(event.tags, bagHeight) + + if (hint == null) { + Output.emit(mapOf("hint" to false, "height" to bagHeight)) + return 0 + } + + val height = bagHeight ?: 0 + Output.emit( + mapOf( + "hint" to true, + "height" to bagHeight, + "box" to CyberspaceCoordinate.encode(hint.base), + "heights" to listOf(hint.heightX, hint.heightY, hint.heightZ), + "gap_bits" to hint.gapBits(height), + "candidates" to hint.candidates(height), + "axis_trees" to hint.axisTrees(height), + "destination" to hint.isDestination(height), + "sector_tags" to hint.sectorTags().map { it.toList() }, + ), + ) + return 0 + } + + /** + * Open a bag with a region key (§7.6). + * + * The key can be named outright (`--key`) or derived here from a + * coordinate (`--coord`), which is the shape that proves the whole chain in + * one line: our §2.2 decode, our Cantor roots and our §7.2 derivation + * against a ciphertext somebody else produced. + * + * **A bag that will not open exits 0.** §7.6: "A failed decryption + * therefore means only that the reader does not hold this region's key; it + * MUST NOT be treated as an error in the bag." So the wrong key is a + * verdict — `opened: false` — the same way `sno validate` reports an + * invalid payload rather than failing. What does fail is a bag this cannot + * attempt at all: an unknown `version`, or no `aes-256-gcm` payload to try. + */ + private fun open(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + val keyHex = args.flag("key") + val coordHex = args.flag("coord") + val heightRaw = args.flag("height") + val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT) + args.rejectUnknown() + + if ((keyHex == null) == (coordHex == null)) { + return Output.error("bad_args", "give exactly one of --key HEX or --coord COORD_HEX") + } + + val bag = bagOf(json) ?: return Output.error("bad_event", "not a kind ${CyberspaceBagEvent.KIND} bag") + if (!bag.isKnownVersion()) { + // §8.6: "A reader MUST ignore a bag whose version it does not know." + return Output.error("unsupported_version", "not a version ${CyberspaceBagEvent.VERSION} bag (§8.6)") + } + if (bag.payload() == null) { + return Output.error("bad_event", "no [\"encrypted\", \"${CyberspaceBagEvent.ALGORITHM}\", …] tag to open") + } + + val height = + if (heightRaw == null) { + bag.height() ?: 0 + } else { + heightRaw.toIntOrNull() ?: return Output.error("bad_args", "--height expects a number, got '$heightRaw'") + } + + val key = + if (keyHex != null) { + keyHex.hexToByteArrayOrNull()?.takeIf { it.size == CyberspaceBagEvent.KEY_BYTES } + ?: return Output.error("bad_args", "--key must be ${CyberspaceBagEvent.KEY_BYTES} bytes of hex") + } else { + val point = + CyberspaceCoordinate.decode(coordHex!!) + ?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex") + if (height < 0) return Output.error("bad_args", "--height must be >= 0") + if (height > maxHeight) return Output.error("too_big", "height $height exceeds max-height $maxHeight") + RegionKey.at(point, height, maxHeight).decryptionKey + } + + val contents = bag.open(key) + if (contents == null) { + Output.emit(mapOf("opened" to false, "height" to height, "lookup_id" to bag.lookupId())) + return 0 + } + + val common = mapOf("opened" to true, "height" to height, "lookup_id" to bag.lookupId()) + when (contents) { + is CyberspaceBagContents.Items -> + Output.emit( + common + + mapOf( + "shape" to "items", + "dropped" to contents.dropped, + "items" to + contents.items.map { item -> + mapOf( + "kind" to item.event.kind, + "id" to item.event.id, + "pubkey" to item.event.pubKey, + // §7.6: authorship only when this is true. + "verified" to item.verified, + "coord" to item.coordinate(), + "event" to Output.mapper.readTree(item.event.toJson()), + ) + }, + ), + ) + + is CyberspaceBagContents.Opaque -> + Output.emit( + common + + mapOf( + "shape" to "opaque", + "bytes" to contents.bytes.size, + "base64" to Base64.encode(contents.bytes), + "text" to asText(contents.bytes), + ), + ) + } + return 0 + } + + /** + * Sweep the box a bag's hint names until its own `lookup_id` comes up + * (§7.7) — the position-free search, priced before it starts. + * + * The price is the point. A hint is a stranger's choice of difficulty, and + * the gap it declares is the exponent of the work: 2^gap region keys. So + * the gap is read and checked against `--max-gap` before the first tree is + * built, and a hint asking for more says so and stops rather than running + * for a day. Raising the ceiling is how a caller spends it on purpose. + */ + private fun sweep(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + val maxGap = args.intFlag("max-gap", DEFAULT_MAX_GAP) + val limit = args.longFlag("limit", Long.MAX_VALUE) + val wantIds = args.bool("ids") + val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT) + args.rejectUnknown() + + val bag = bagOf(json) ?: return Output.error("bad_event", "not a kind ${CyberspaceBagEvent.KIND} bag") + val height = bag.height() ?: return Output.error("no_hint", "a sweep needs the bag's `h` tag (§8.6)") + val hint = bag.hint() ?: return Output.error("no_hint", "no usable `hint` tag to sweep (§7.7)") + + val gap = hint.gapBits(height) + if (gap > maxGap) { + return Output.error( + "too_big", + "a gap of $gap bits is 2^$gap region keys; pass --max-gap $gap to spend it", + extra = mapOf("gap_bits" to gap, "max_gap" to maxGap), + ) + } + if (limit < 1) return Output.error("bad_args", "--limit must be >= 1") + + // What the hider published as the address, and therefore the only thing + // a sweep can recognise when it walks past the right region. + val target = bag.lookupId() + val ids = if (wantIds) mutableListOf() else null + + var examined = 0L + var exhausted = true + var found: RegionKeyMaterial? = null + val candidates = + try { + RegionSweep.of(hint, height, maxHeight) + } catch (e: IllegalArgumentException) { + return Output.error("too_big", e.message) + } + + for (material in candidates) { + examined++ + ids?.add(material.lookupId) + if (target != null && material.lookupId == target) { + found = material + exhausted = false + break + } + if (examined >= limit) { + exhausted = false + break + } + } + + Output.emit( + mapOf( + "height" to height, + "gap_bits" to gap, + "candidates" to hint.candidates(height), + "axis_trees" to hint.axisTrees(height), + "target" to target, + "examined" to examined, + "exhausted" to exhausted, + "found" to (found != null), + "key" to found?.decryptionKey?.toHexKey(), + "lookup_id" to found?.lookupId, + "lookup_ids" to ids, + ), + ) + return 0 + } + + /** + * Read a kind-33330 bag out of raw JSON. + * + * [Event.fromJson] already answers with a [CyberspaceBagEvent] for a + * registered kind; the rebuild is for the caller that hands us an event + * from a factory that did not, so the verb behaves the same either way. + */ + private fun bagOf(json: String): CyberspaceBagEvent? { + val event = + try { + Event.fromJson(json) + } catch (_: Exception) { + return null + } + if (event is CyberspaceBagEvent) return event + if (event.kind != CyberspaceBagEvent.KIND) return null + return CyberspaceBagEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig) + } + + /** + * These bytes as text, or null when they are not UTF-8 — §7.6's opaque + * shape is "a text note or a file", and a file should not be printed as a + * field of replacement characters. + */ + private fun asText(bytes: ByteArray): String? { + val text = bytes.decodeToString() + return if (text.encodeToByteArray().contentEquals(bytes)) text else null + } + + /** + * The gap a sweep spends without being told to. + * + * 2^20 is about a million region keys — a minute or so of a laptop, and the + * scale §7.7's own table calls a reasonable search. Everything past it is + * the caller's decision to make in the command line, because §7.7's larger + * boxes run from "hours" to "days to never" and nothing about a bag tells + * you which one a stranger meant. + */ + private const val DEFAULT_MAX_GAP = 20 + + /** An 85-bit axis as decimal, from its two halves, without a big integer. */ + private fun decimal( + high: Long, + low: Long, + ): String { + // high * 2^64 + low, with low unsigned. Done in base 10^9 chunks so the + // CLI does not need arbitrary precision for a number it only prints. + var result = "0" + for (bit in 84 downTo 0) { + result = addDecimal(result, result) + val set = if (bit >= 64) (high ushr (bit - 64)) and 1L else (low ushr bit) and 1L + if (set == 1L) result = addDecimal(result, "1") + } + return result + } + + private fun addDecimal( + a: String, + b: String, + ): String { + val out = StringBuilder() + var carry = 0 + var i = a.length - 1 + var j = b.length - 1 + while (i >= 0 || j >= 0 || carry > 0) { + val sum = (if (i >= 0) a[i--] - '0' else 0) + (if (j >= 0) b[j--] - '0' else 0) + carry + out.append(('0' + sum % 10)) + carry = sum / 10 + } + return out.reverse().toString() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt new file mode 100644 index 0000000000..5e3e29d67f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt @@ -0,0 +1,181 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarWork +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.math.abs + +/** + * `amy sno …` — read Simple Nostr Objects (DECK-0003), local and accountless. + * + * Thin assembly only: every rule lives in quartz's `cyberspace/deck0003Sno/`. + * These verbs exist so the Kotlin reader can be diffed against the deck's own + * `sno-reference.py` and against cyberspace-cli without a device or a relay — + * see `cli/tests/sno/`. + */ +object SnoCommands { + val USAGE: String = + """ + |amy sno — Simple Nostr Objects, DECK-0003 (local, accountless) + | + | sno parse [PAYLOAD|-] validate a payload against §1.9 + | sno work [PAYLOAD|-] the proof of work an avatar owes for it (§8.10) + | sno verify [EVENT|-] whether a kind 11333 avatar event has paid + | + |Each reads its argument as JSON, or from stdin when it is omitted or `-`. + """.trimMargin() + + suspend fun dispatch(tail: Array): Int = + route( + "sno", + tail, + "sno ", + mapOf( + "parse" to { rest -> parse(rest) }, + "work" to { rest -> work(rest) }, + "verify" to { rest -> verify(rest) }, + ), + USAGE, + ) + + /** + * Validate a payload. `valid` is the answer; on a refusal `rule` names the + * numbered rule of §1.9 that failed, which is what makes a verdict + * comparable with `sno-reference.py`'s. + */ + private fun parse(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + return when (val result = SnoParser.parse(json)) { + is SnoResult.Invalid -> { + Output.emit(mapOf("valid" to false, "rule" to result.rule, "reason" to result.reason)) + 0 + } + is SnoResult.Valid -> { + Output.emit(mapOf("valid" to true) + facts(result.payload)) + 0 + } + } + } + + private fun work(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + val payload = + SnoParser.parse(json).payloadOrNull() + ?: return Output.error("bad_payload", "not a valid SNO payload") + + Output.emit( + mapOf( + "required" to SnoAvatarWork.required(payload), + "reach_ticks" to reachTicks(payload), + "unit" to payload.unit, + "detail" to maxOf(SnoAvatarWork.DETAIL_FREE, payload.vertexCount + payload.faceCount), + ), + ) + return 0 + } + + /** + * Whether an avatar event may be drawn. The keys mirror + * `verify_avatar_work` in the reference implementations so the two can be + * diffed directly. + */ + private fun verify(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + val event = + try { + Event.fromJson(json) + } catch (_: Exception) { + return Output.error("bad_event", "not a nostr event") + } + + val avatar = + event as? SnoAvatarEvent + ?: return Output + .emit( + mapOf("ok" to false, "required" to 0, "committed" to null, "zeros" to 0, "reason" to "not-an-avatar"), + ).let { 0 } + + val payment = avatar.payment() + Output.emit( + mapOf( + "ok" to payment.ok, + "required" to payment.required, + "committed" to payment.committed, + "zeros" to payment.zeros, + "reason" to payment.reason.code, + ), + ) + return 0 + } + + /** The facts a conformance diff cares about, and nothing else. */ + private fun facts(payload: SnoPayload): Map = + mapOf( + "version" to payload.version, + "name" to payload.name, + "unit" to payload.unit, + "extent" to payload.extent, + "mode" to payload.mode.code, + "vertices" to payload.vertexCount, + "faces" to payload.faceCount, + "has_face_colors" to (payload.faceColors != null), + "palette" to + when (payload.paletteRef) { + SnoPaletteRef.BuiltIn -> "built-in" + is SnoPaletteRef.Inline -> "inline" + is SnoPaletteRef.Event -> "event" + }, + "up" to payload.up, + "spin" to payload.spin, + "reach_ticks" to reachTicks(payload), + ) + + /** + * The farthest any vertex lies from the build origin, in ticks — the + * integer half of §8.10's reach, before the scale exponent is applied. + * Reported as an integer so a diff never argues about float formatting. + */ + private fun reachTicks(payload: SnoPayload): Int { + var farthest = 0 + for (tick in payload.positions) { + val magnitude = abs(tick) + if (magnitude > farthest) farthest = magnitude + } + return farthest + } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index caa8f88122..c91ed90712 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -38,6 +38,9 @@ cli/tests/ │ └── README.md # operator brief + per-test matrix ├── pow/ # NIP-13 primitives (bench/mine/check) — no relay │ └── pow-headless.sh +├── sno/ # DECK-0003 conformance vs the cyberspace project's +│ ├── sno-conformance.sh # own reference implementations — no relay +│ └── refdriver.py ├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode) │ └── relaygroup-headless.sh └── sync/ # NIP-77 deletion propagation vs `amy serve` @@ -58,6 +61,14 @@ Suite notes: (the same fixtures quartz's `ClinkInteropTest` uses) to the right fields, plus the argument-error paths. The round-trip verbs (`offer request`, `debit pay/budget`) need a live CLINK service and aren't covered here. +- **`sno/sno-conformance.sh`** is relay-free and diffs `amy sno` against the + cyberspace project's own references: the deck's `sno-reference.py` for §1.9 + verdicts (the rejection table drives the run, so the fixtures are theirs and + not ours) and cyberspace-cli's `avatar.py` for the §8.10 work ladder and its + golden vectors. The two places our reader knowingly differs are asserted as + divergences rather than skipped, so neither can drift quietly. Reference + checkouts are cloned into `state/` unless `CYBERSPACE_DIR` / + `CYBERSPACE_CLI_DIR` already point at them. - **`pow/pow-headless.sh`** is also relay-free: `pow bench` sanity, `pow mine` hitting its target (and exiting 124 on an impossible one), and mined-nonce round-trips through `pow check`. diff --git a/cli/tests/sno/.gitignore b/cli/tests/sno/.gitignore new file mode 100644 index 0000000000..268b1592e6 --- /dev/null +++ b/cli/tests/sno/.gitignore @@ -0,0 +1 @@ +state-sno-conformance/ diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py new file mode 100755 index 0000000000..5ef34f02d4 --- /dev/null +++ b/cli/tests/sno/refdriver.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Adapter over the cyberspace project's own reference implementations. + +Speaks one JSON object per line on stdout so `sno-conformance.sh` can diff it +against `amy sno … --json` with jq. Nothing here reimplements anything: it +imports `decks/sno-reference.py` from the cyberspace spec repo (the §1.9 +arbiter) and `cyberspace_core/avatar.py` from cyberspace-cli (the §8.10 work +and payment reference), and just re-emits what they answer. + + refdriver.py cases the deck's own rejection table, + Appendix A + refdriver.py vectors cyberspace-cli's avatar-work golden vectors + refdriver.py verdict < payload {"valid":…, "rule":…} + refdriver.py work < payload {"required":…} + refdriver.py verify < event {"ok":…, "required":…, "committed":…, "zeros":…, "reason":…} + refdriver.py region COORD HEIGHT {"key":…, "lookup_id":…, "x":…, "y":…, "z":…, "plane":…} + refdriver.py hints §7.7's golden hint vectors, one per line + refdriver.py encrypt COORD HEIGHT seal stdin into a kind-33330 bag at that region + refdriver.py box COORD HX HY HZ the §7.7 hint tag for the box around that coord + +Paths come from $CYBERSPACE_DIR and $CYBERSPACE_CLI_DIR. +""" +import importlib.util +import json +import os +import signal +import sys + +# Used in pipelines (`| head`), where dying on SIGPIPE is the right answer +# rather than a traceback. +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + +def _load(path, name): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None: + raise SystemExit(f"cannot load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def _sno(): + root = os.environ.get("CYBERSPACE_DIR") + if not root: + raise SystemExit("CYBERSPACE_DIR is not set") + return _load(os.path.join(root, "decks", "sno-reference.py"), "sno_reference") + + +def _avatar(): + root = os.environ.get("CYBERSPACE_CLI_DIR") + if not root: + raise SystemExit("CYBERSPACE_CLI_DIR is not set") + sys.path.insert(0, os.path.join(root, "src")) + return _load(os.path.join(root, "src", "cyberspace_core", "avatar.py"), "ref_avatar") + + +def emit(obj): + print(json.dumps(obj, separators=(",", ":"), sort_keys=True)) + + +def cmd_cases(): + sno = _sno() + emit({"name": "appendix A", "rule": None, "payload": dict(sno.APPENDIX_A)}) + for label, payload in sno._rejections(): + emit({"name": label, "rule": label.replace("rule ", ""), "payload": payload}) + + +def cmd_vectors(): + root = os.environ.get("CYBERSPACE_CLI_DIR") + if not root: + raise SystemExit("CYBERSPACE_CLI_DIR is not set") + with open(os.path.join(root, "tests", "fixtures", "avatar_work.json")) as fh: + for case in json.load(fh): + emit({"name": case["name"], "required": case["required"], "payload": case["payload"]}) + + +def cmd_verdict(): + sno = _sno() + payload = json.load(sys.stdin) + try: + sno.validate(dict(payload)) + emit({"valid": True, "rule": None}) + except sno.SnoError as err: + text = str(err) + rule = text.split(":")[0].replace("rule ", "").strip() if text.startswith("rule ") else "?" + emit({"valid": False, "rule": rule}) + + +def cmd_work(): + avatar = _avatar() + emit({"required": avatar.avatar_work(json.load(sys.stdin))}) + + +def cmd_verify(): + avatar = _avatar() + result = avatar.verify_avatar_work(json.load(sys.stdin)) + emit({k: result[k] for k in ("ok", "required", "committed", "zeros", "reason")}) + + +def cmd_region(): + """§2.2 decode plus §7.2 derivation, from cyberspace-cli's own modules. + + `location_encryption.py` imports AESGCM at module scope and that binding is + not always present; `cantor` and `movement` are the whole of what a region + key needs, and going through them keeps this an adapter rather than a + reimplementation. + """ + sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src")) + from cyberspace_core.coords import coord_to_xyz + from cyberspace_core.cantor import cantor_pair, int_to_bytes_be_min, sha256 + from cyberspace_core.movement import compute_subtree_cantor + + coord_hex, height = sys.argv[2], int(sys.argv[3]) + x, y, z, plane = coord_to_xyz(int(coord_hex, 16)) + base = lambda v: (v >> height) << height if height > 0 else v + region_n = cantor_pair( + cantor_pair( + compute_subtree_cantor(base(x), height), + compute_subtree_cantor(base(y), height), + ), + compute_subtree_cantor(base(z), height), + ) + key = sha256(int_to_bytes_be_min(region_n)) + emit({ + "key": key.hex(), + "lookup_id": sha256(key).hex(), + "x": str(x), "y": str(y), "z": str(z), + "plane": "ideaspace" if plane else "dataspace", + }) + + +def cmd_hints(): + """§7.7's golden vectors, straight out of the spec's own hint-reference.py.""" + root = os.environ["CYBERSPACE_DIR"] + # It sits at the repo root, beside CYBERSPACE_V2.md, rather than under + # decks/ where the DECK references live; accept either in case it moves. + path = next( + (p for p in (os.path.join(root, "hint-reference.py"), os.path.join(root, "decks", "hint-reference.py")) if os.path.exists(p)), + None, + ) + if path is None: + raise SystemExit("hint-reference.py not found under $CYBERSPACE_DIR") + hint_ref = _load(path, "hint_reference") + for name, vector in hint_ref.vectors().items(): + emit({"name": name, **vector}) + + +def cmd_encrypt(): + """Seal stdin into a §8.6 bag, using cyberspace-cli's own cipher and event builder. + + The point of the round trip is that nothing on this side is ours: the key + comes from `location_encryption`, the sealing from `encrypt_with_location_key` + and the tags from `make_encrypted_content_event`. What `amy cyberspace open` + then has to do is derive the same key from the same coordinate and read a + ciphertext it never saw made. + + A fixed nonce keeps the fixture reproducible; §7.6 wants a fresh one per + bag, which matters for a hider and not for a test that seals once. + """ + sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src")) + import base64 + + from cyberspace_core.coords import coord_to_xyz + from cyberspace_cli.nostr_event import make_encrypted_content_event + from cyberspace_core.location_encryption import ( + derive_region_key_material_for_height, + encrypt_with_location_key, + ) + + coord_hex, height = sys.argv[2], int(sys.argv[3]) + x, y, z, _plane = coord_to_xyz(int(coord_hex, 16)) + material = derive_region_key_material_for_height(x=x, y=y, z=z, height=height) + payload = encrypt_with_location_key( + sys.stdin.buffer.read(), + location_decryption_key=material.location_decryption_key, + nonce=bytes(range(12)), + ) + event = make_encrypted_content_event( + pubkey_hex="b" * 64, + created_at=1, + lookup_id_hex=material.lookup_id_hex, + algorithm="aes-256-gcm", + ciphertext_b64=base64.b64encode(payload).decode("ascii"), + height_hint=height, + content="", + kind=33330, + ) + emit({ + "event": event, + "key": material.location_decryption_key.hex(), + "lookup_id": material.lookup_id_hex, + }) + + +def cmd_box(): + """The §7.7 `hint` tag naming the aligned box around a coordinate. + + Built from the reference's own interleave so a sweep test is not marking + its own homework: the box comes from their `coord_to_xyz`/`xyz_to_coord`, + and if our alignment disagreed the bag would simply not be in the box we + were handed. + """ + sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src")) + from cyberspace_core.coords import coord_to_xyz, xyz_to_coord + + coord_hex = sys.argv[2] + hx, hy, hz = (int(v) for v in sys.argv[3:6]) + x, y, z, plane = coord_to_xyz(int(coord_hex, 16)) + base = xyz_to_coord((x >> hx) << hx, (y >> hy) << hy, (z >> hz) << hz, plane) + emit({"tag": ["hint", f"{base:064x}", str(hx), str(hy), str(hz)]}) + + +COMMANDS = { + "box": cmd_box, + "cases": cmd_cases, + "encrypt": cmd_encrypt, + "region": cmd_region, + "hints": cmd_hints, + "vectors": cmd_vectors, + "verdict": cmd_verdict, + "work": cmd_work, + "verify": cmd_verify, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + raise SystemExit(f"usage: refdriver.py <{'|'.join(COMMANDS)}>") + COMMANDS[sys.argv[1]]() diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh new file mode 100755 index 0000000000..46f59c8c39 --- /dev/null +++ b/cli/tests/sno/sno-conformance.sh @@ -0,0 +1,510 @@ +#!/usr/bin/env bash +# +# sno-conformance.sh — diffs amy's DECK-0003 reader against the cyberspace +# project's own reference implementations. No relay, no account, no device. +# +# Eight comparisons, each driven by the reference's own fixtures rather than +# by anything we wrote: +# +# 1. §1.9 verdicts — the deck's rejection table (`_rejections()` in +# decks/sno-reference.py) plus Appendix A, through +# `amy sno parse` and through the reference. The +# valid/invalid answer AND the rule number must agree. +# 2. Avatar work — the golden vectors in cyberspace-cli's +# tests/fixtures/avatar_work.json, which §8.10 says +# both reference implementations are pinned to, +# through `amy sno work` and `avatar_work()`. +# 3. Avatar payment — synthesised events through `amy sno verify` and +# `verify_avatar_work()`. +# 4. Reader divergence — where we knowingly differ from the §1.9 arbiter, +# and one place we used to and no longer do, pinned so +# neither can drift quietly. +# 5. Region keys — §2.2 decodes and §7.2 keys against cyberspace-cli. +# 6. Hint boxes — §7.7's golden vectors through `amy cyberspace hint`. +# 7. Bags — a ciphertext cyberspace-cli sealed, opened by +# `amy cyberspace open` from the coordinate alone. +# 8. Sweeps — the same bag found by `amy cyberspace sweep` from +# its hint box, with no coordinate at all. +# +# Divergences we already know about are asserted as divergences, not ignored: +# a reader that silently stopped diverging would be just as interesting as one +# that started. +# +# Usage: ./sno-conformance.sh [--no-build] +# +# Reference checkouts are cloned into state/ unless CYBERSPACE_DIR and +# CYBERSPACE_CLI_DIR already point at them. +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-sno-conformance" +LOG_DIR="$STATE_DIR/logs" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" +DRIVER="$SCRIPT_DIR/refdriver.py" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) NO_BUILD=1 ;; + -h|--help) sed -n '3,33p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$SCRIPT_DIR/../lib.sh" + +command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; } +command -v python3 >/dev/null || { fail_msg "python3 is required"; exit 1; } + +# ---- the reference checkouts ------------------------------------------------ + +clone_ref() { + local url="$1" dest="$2" + if [[ -d "$dest/.git" ]]; then info "reusing $dest"; return 0; fi + step "cloning $url" + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet --depth 1 "$url" "$dest" >>"$LOG_FILE" 2>&1 +} + +: "${CYBERSPACE_DIR:=$STATE_DIR/cyberspace}" +: "${CYBERSPACE_CLI_DIR:=$STATE_DIR/cyberspace-cli}" +export CYBERSPACE_DIR CYBERSPACE_CLI_DIR + +if [[ ! -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]]; then + clone_ref https://github.com/arkin0x/cyberspace "$CYBERSPACE_DIR" || true +fi +if [[ ! -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]]; then + clone_ref https://github.com/arkin0x/cyberspace-cli "$CYBERSPACE_CLI_DIR" || true +fi + +HAVE_SNO_REF=0; [[ -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]] && HAVE_SNO_REF=1 +HAVE_AVATAR_REF=0; [[ -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]] && HAVE_AVATAR_REF=1 + +if [[ $NO_BUILD -eq 0 ]]; then + step "building amy (installDist)" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "gradle :cli:installDist failed"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy not built at $AMY_BIN"; exit 1; } + +amy() { "$AMY_BIN" --json "$@" 2>>"$LOG_FILE"; } +ref() { python3 "$DRIVER" "$@" 2>>"$LOG_FILE"; } + +# ---- 1. §1.9 verdicts ------------------------------------------------------- + +banner "1. §1.9 verdicts — the deck's own rejection table" + +if [[ $HAVE_SNO_REF -eq 0 ]]; then + skip_msg "sno-reference.py unavailable" + record_result "sno-verdicts" skip "no cyberspace checkout" +else + AGREE=0; DISAGREE=0; DIVERGENCES="" + while IFS= read -r line; do + NAME="$(jq -r .name <<<"$line")" + PAYLOAD="$(jq -c .payload <<<"$line")" + + OURS="$(printf '%s' "$PAYLOAD" | amy sno parse -)" + THEIRS="$(printf '%s' "$PAYLOAD" | ref verdict)" + + OUR_VALID="$(jq -r '.valid' <<<"$OURS")" + OUR_RULE="$(jq -r '.rule // "null"' <<<"$OURS")" + THEIR_VALID="$(jq -r '.valid' <<<"$THEIRS")" + THEIR_RULE="$(jq -r '.rule // "null"' <<<"$THEIRS")" + + if [[ "$OUR_VALID" == "$THEIR_VALID" && "$OUR_RULE" == "$THEIR_RULE" ]]; then + AGREE=$((AGREE+1)) + else + DISAGREE=$((DISAGREE+1)) + DIVERGENCES+=" $NAME: amy(valid=$OUR_VALID rule=$OUR_RULE) ref(valid=$THEIR_VALID rule=$THEIR_RULE)"$'\n' + fi + done < <(ref cases) + + info "$AGREE agreed, $DISAGREE diverged" + [[ -n "$DIVERGENCES" ]] && printf '%s' "$DIVERGENCES" >&2 + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "sno-verdicts" pass "$AGREE cases agree" + else + record_result "sno-verdicts" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 2. avatar work --------------------------------------------------------- + +banner "2. avatar work — cyberspace-cli's golden vectors" + +if [[ $HAVE_AVATAR_REF -eq 0 ]]; then + skip_msg "cyberspace-cli unavailable" + record_result "sno-avatar-work" skip "no cyberspace-cli checkout" +else + AGREE=0; DISAGREE=0 + while IFS= read -r line; do + NAME="$(jq -r .name <<<"$line")" + EXPECTED="$(jq -r .required <<<"$line")" + PAYLOAD="$(jq -c .payload <<<"$line")" + THEIRS="$(printf '%s' "$PAYLOAD" | ref work | jq -r .required)" + + # The fixtures carry only the fields the formula reads, and some index + # faces past the vertex list because it never looks at them — so dress + # each one as a payload §1.9 accepts, keeping the counts and the reach. + FULL="$(jq -c --argjson p "$PAYLOAD" -n ' + ($p.vertices | length) as $nv + | {v:2, name:"vector", unit:($p.unit // 0), + mode:(if (($p.faces // []) | length) > 0 then "solid" else "points" end), + vertices:$p.vertices, + colors:[range($nv) | 225], + faces:(if $nv >= 3 then [range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]] else [] end)} + + (if $p.ticks then {ticks:$p.ticks} else {} end)')" + + NV="$(jq -r '.vertices | length' <<<"$PAYLOAD")" + NF="$(jq -r '(.faces // []) | length' <<<"$PAYLOAD")" + if [[ "$NV" -lt 3 && "$NF" -gt 0 ]]; then + fail_msg "$NAME: $NF faces over $NV vertices cannot be expressed as a §1.9 payload" + DISAGREE=$((DISAGREE+1)) + continue + fi + OURS="$(printf '%s' "$FULL" | amy sno work | jq -r .required)" + + if [[ "$OURS" == "$THEIRS" && "$OURS" == "$EXPECTED" ]]; then + AGREE=$((AGREE+1)) + info "$NAME: $OURS bits" + else + DISAGREE=$((DISAGREE+1)) + fail_msg "$NAME: amy=$OURS ref=$THEIRS fixture=$EXPECTED" + fi + done < <(ref vectors) + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "sno-avatar-work" pass "$AGREE golden vectors agree" + else + record_result "sno-avatar-work" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 3. avatar payment, and the divergences we expect ----------------------- + +banner "3. avatar payment — and the divergences we know about" + +SHAPE='{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}' + +# id with exactly N leading zero bits, padded to 64 hex chars +id_with_bits() { + python3 - "$1" <<'PY' +import sys +bits = int(sys.argv[1]) +head = "0" * (bits // 4) + {0: "f", 1: "4", 2: "2", 3: "1"}[bits % 4] +print(head + "f" * (64 - len(head))) +PY +} + +avatar_event() { # kind, id-bits, committed|none, content + local kind="$1" bits="$2" committed="$3" content="$4" + local tags="[]" + [[ "$committed" != "none" ]] && tags="[[\"nonce\",\"1\",\"$committed\"]]" + jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \ + --argjson tags "$tags" --arg content "$content" \ + '{id:$id, pubkey:("11"*32), kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' +} + +check_verify() { # label, event, expected-amy-reason, expected-ref-reason + local label="$1" event="$2" want_ours="$3" want_theirs="$4" + local ours theirs + ours="$(printf '%s' "$event" | amy sno verify - | jq -r .reason)" + if [[ $HAVE_AVATAR_REF -eq 1 ]]; then + theirs="$(printf '%s' "$event" | ref verify | jq -r .reason)" + else + theirs="$want_theirs" + fi + if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then + record_result "$label" pass "amy=$ours ref=$theirs" + else + record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)" + fi +} + +# A paid avatar. The reference wants kind 33331 — it predates the move to +# 11333 that §8.10 documents — so it refuses a conformant one outright. +check_verify "verify-paid" "$(avatar_event 11333 16 16 "$SHAPE")" ok not-an-avatar +check_verify "verify-no-nonce" "$(avatar_event 11333 32 none "$SHAPE")" no-nonce not-an-avatar +check_verify "verify-under-committed" "$(avatar_event 11333 32 8 "$SHAPE")" under-committed not-an-avatar + +# The trap: committed 30, owes 16, id carries 20. min(20,30)=20 clears 16, so +# a reader comparing the wrong number calls this paid. Both of us refuse it. +check_verify "verify-short-of-commitment" "$(avatar_event 11333 20 30 "$SHAPE")" unpaid not-an-avatar + +# Empty content is §8.10's default avatar and owes no work; the reference +# calls it not-an-avatar because empty is not JSON. Same outcome — the client +# draws its default — different word for it. +check_verify "verify-default-avatar" "$(avatar_event 11333 0 none "")" default-avatar not-an-avatar + +# The reference's own kind. We refuse it as an avatar because DECK-0003 gave +# 33331 to standalone objects; it accepts it and prices it. +check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an-avatar ok + +# ---- 4. the two places our reader knowingly differs ------------------------ + +banner "4. reader divergence and agreement — pinned, not ignored" + +# None of these are in the deck's rejection table, so section 1 never sees +# them. Each is deliberate and documented; a change in either direction is +# news, including a divergence that quietly reappears after being repealed. + +check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, note + local label="$1" payload="$2" want_ours="$3" want_theirs="$4" note="$5" + local ours theirs + ours="$(printf '%s' "$payload" | amy sno parse - | jq -r .valid)" + if [[ $HAVE_SNO_REF -eq 1 ]]; then + theirs="$(printf '%s' "$payload" | ref verdict | jq -r .valid)" + else + theirs="$want_theirs" + fi + if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then + record_result "$label" pass "amy=$ours ref=$theirs — $note" + else + record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)" + fi +} + +# D1. A v:2 payload whose colours are still literal triples. Version 2 shipped +# as two changes that did not land together, so these exist and are right in +# every other respect; §5 permits a reader to be generous and sno-core is. +# Three of the seven objects on the network are this shape. +TRIPLES='{"v":2,"name":"legacy","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]],"faces":[[0,1,2]]}' +check_verdict "divergence-v2-triples" "$TRIPLES" true false "we read the legacy colour cohort; the arbiter refuses it" + +# D5, repealed. A vertex past 64 units. §1.8 puts that bound on publishers only +# and gives a reader the choice — "MAY reject such a payload and MAY instead +# repair it by growing the extent" — and neither reference bounds it: both grow +# the extent. Amethyst rejected, which made it the only reader that refused an +# object the rest of the network drew, so it repairs now and this is pinned as +# agreement. A reader that started refusing again would be news. +FAR='{"v":2,"name":"far","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[9999,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}' +check_verdict "agreement-position-repair" "$FAR" true true "both repair a vertex past the grid rather than refusing it" + +# D5b. What is left of that bound: the lattice itself. A position is a total +# tick count in an Int, so past Int.MAX_VALUE ticks — 17,895,697 units — there +# is no coordinate to repair, only one that would wrap. The reference keeps its +# positions in a double and carries this one fine, which is the divergence. +WRAP='{"v":2,"name":"wrap","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}' +check_verdict "divergence-lattice-limit" "$WRAP" false true "past what an Int lattice holds we refuse; the arbiter keeps it in a float" + +# ---- 5. cyberspace region keys --------------------------------------------- + +banner "5. region keys — CYBERSPACE_V2 §2.2 and §7.2 against cyberspace-cli" + +# A region key is a consensus value: §7.2 turns it into an AES key, so a byte +# of difference is a bag they hid that we cannot open. Four coordinates from +# §9.8's golden vectors (plus §7.7's ideaspace point) at four heights each. +if [[ $HAVE_AVATAR_REF -eq 0 ]]; then + skip_msg "cyberspace-cli unavailable" + record_result "cyberspace-region-keys" skip "no cyberspace-cli checkout" +else + AGREE=0; DISAGREE=0 + for COORD in \ + c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940 \ + c4924924924924924924921f79235dae293ada913e78294253a235239a332854 \ + e000000000000000000001200041040208048040000000000000000000000000 \ + a4b64924924924924924924924924924924924924924924924924d84b60d9c8f + do + for H in 0 1 4 8; do + THEIRS="$(ref region "$COORD" "$H")" + OURS="$(amy cyberspace region "$COORD" --height "$H")" + T_KEY="$(jq -r .key <<<"$THEIRS")"; O_KEY="$(jq -r .key <<<"$OURS")" + T_ID="$(jq -r .lookup_id <<<"$THEIRS")"; O_ID="$(jq -r .lookup_id <<<"$OURS")" + # And the decode underneath it, which is where a wrong bit would start. + T_XYZ="$(jq -r '"\(.x) \(.y) \(.z) \(.plane)"' <<<"$THEIRS")" + O_XYZ="$(amy cyberspace coord "$COORD" | jq -r '"\(.x) \(.y) \(.z) \(.plane)"')" + + if [[ "$O_KEY" == "$T_KEY" && "$O_ID" == "$T_ID" && "$O_XYZ" == "$T_XYZ" ]]; then + AGREE=$((AGREE+1)) + else + DISAGREE=$((DISAGREE+1)) + fail_msg "${COORD:0:8}… h$H: key amy=${O_KEY:0:16} ref=${T_KEY:0:16}; xyz amy=[$O_XYZ] ref=[$T_XYZ]" + fi + done + done + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "cyberspace-region-keys" pass "$AGREE keys and decodes agree" + else + record_result "cyberspace-region-keys" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 6. §7.7 hint boxes ----------------------------------------------------- + +banner "6. hint boxes — §7.7's golden vectors through amy" + +# A hint is the hider's difficulty knob: the box it names decides how many +# region keys a seeker has to derive. Getting the aligned base or the sector +# rule wrong means sweeping the wrong box, which finds nothing and says so +# only after the work is done. +if [[ $HAVE_SNO_REF -eq 0 ]]; then + skip_msg "cyberspace checkout unavailable" + record_result "cyberspace-hints" skip "no cyberspace checkout" +else + AGREE=0; DISAGREE=0 + while IFS= read -r line; do + NAME="$(jq -r .name <<<"$line")" + H="$(jq -r .bag_height <<<"$line")" + WANT_LOG2="$(jq -r .candidates_log2 <<<"$line")" + WANT_HINT="$(jq -c '[.tags[] | select(.[0] == "hint")][0]' <<<"$line")" + WANT_SECTORS="$(jq -c '[.tags[] | select(.[0] != "hint")]' <<<"$line")" + + # A bag carrying exactly the tags the reference says a hider writes. + BAG="$(jq -cn --argjson tags "$(jq -c .tags <<<"$line")" --arg h "$H" ' + {id:("a"*64), pubkey:("b"*64), created_at:1, kind:33330, + tags:([["d",("c"*64)],["h",$h]] + $tags), content:"", sig:("0"*128)}')" + + OURS="$(printf '%s' "$BAG" | amy cyberspace hint -)" + GOT_HINT="$(jq -c '["hint", .box, (.heights[0]|tostring), (.heights[1]|tostring), (.heights[2]|tostring)]' <<<"$OURS")" + GOT_SECTORS="$(jq -c '.sector_tags' <<<"$OURS")" + GOT_LOG2="$(jq -r .gap_bits <<<"$OURS")" + + if [[ "$GOT_HINT" == "$WANT_HINT" && "$GOT_SECTORS" == "$WANT_SECTORS" && "$GOT_LOG2" == "$WANT_LOG2" ]]; then + AGREE=$((AGREE+1)) + info "$NAME: 2^$GOT_LOG2 candidates, $(jq -r '.sector_tags | length' <<<"$OURS") sector tags" + else + DISAGREE=$((DISAGREE+1)) + fail_msg "$NAME: hint amy=$GOT_HINT ref=$WANT_HINT; sectors amy=$GOT_SECTORS ref=$WANT_SECTORS; gap amy=$GOT_LOG2 ref=$WANT_LOG2" + fi + done < <(ref hints) + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "cyberspace-hints" pass "$AGREE golden vectors agree" + else + record_result "cyberspace-hints" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 7. §7.6 bags, round-tripped through the reference's cipher ------------- + +banner "7. bags — sealed by cyberspace-cli, opened by amy" + +# The strongest statement this harness can make about §7: a ciphertext nobody +# here produced. cyberspace-cli derives the region key with its own modules, +# seals a plaintext with its own AES-GCM and writes the §8.6 tags with its own +# event builder; amy is handed nothing but that event and a coordinate, and has +# to arrive at the same 32 bytes to read it. Every link in §2.2 -> §4.7 -> §7.2 +# -> §7.6 is inside that one assertion. +if [[ $HAVE_AVATAR_REF -eq 0 ]]; then + skip_msg "cyberspace-cli unavailable" + record_result "cyberspace-bags" skip "no cyberspace-cli checkout" +else + AGREE=0; DISAGREE=0 + for COORD in \ + c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940 \ + a4b64924924924924924924924924924924924924924924924924d84b60d9c8f + do + for H in 0 4; do + PLAIN="a bag at ${COORD:0:8} height $H" + SEALED="$(printf '%s' "$PLAIN" | ref encrypt "$COORD" "$H")" + BAG="$(jq -c .event <<<"$SEALED")" + + # Opened from the coordinate alone: our decode, our Cantor roots, our + # §7.2 derivation, their ciphertext. + GOT="$(printf '%s' "$BAG" | amy cyberspace open - --coord "$COORD")" + OPENED="$(jq -r .opened <<<"$GOT")" + TEXT="$(jq -r '.text // ""' <<<"$GOT")" + + # And the negative §7.6 insists on: the wrong key is a verdict, not an + # error. "A failed decryption therefore means only that the reader does + # not hold this region's key; it MUST NOT be treated as an error." + WRONG="$(printf '%s' "$BAG" | amy cyberspace open - --key "$(printf 'ab%.0s' $(seq 32))")" + WRONG_EXIT=$? + WRONG_OPENED="$(jq -r '.opened' <<<"$WRONG")" + + if [[ "$OPENED" == "true" && "$TEXT" == "$PLAIN" && "$WRONG_OPENED" == "false" && $WRONG_EXIT -eq 0 ]]; then + AGREE=$((AGREE+1)) + info "${COORD:0:8}… h$H: opened their ciphertext from the coordinate" + else + DISAGREE=$((DISAGREE+1)) + fail_msg "${COORD:0:8}… h$H: opened=$OPENED text='$TEXT' want='$PLAIN'; wrong-key opened=$WRONG_OPENED exit=$WRONG_EXIT" + fi + done + done + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "cyberspace-bags" pass "$AGREE bags sealed there, opened here" + else + record_result "cyberspace-bags" fail "$DISAGREE of $((AGREE+DISAGREE)) failed to round-trip" + fi +fi + +# ---- 8. §7.7 sweeps --------------------------------------------------------- + +banner "8. sweeps — finding a bag from its hint box alone" + +# The same bag, this time without being told where it is. amy gets the box and +# the bag's height, derives every candidate region key inside it and looks for +# the one whose lookup_id is the `d` tag the hider published — §7.7's +# position-free search, end to end. The box itself comes from the reference's +# interleave, so a disagreement about alignment shows up as a bag that is not +# in the box rather than as a test grading its own arithmetic. +if [[ $HAVE_AVATAR_REF -eq 0 ]]; then + skip_msg "cyberspace-cli unavailable" + record_result "cyberspace-sweeps" skip "no cyberspace-cli checkout" +else + AGREE=0; DISAGREE=0 + COORD=c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940 + SEALED="$(printf 'found by sweeping' | ref encrypt "$COORD" 4)" + WANT_KEY="$(jq -r .key <<<"$SEALED")" + + # A cube (gap 6), a slab with one axis pinned to the bag's own height (gap 4), + # and the degenerate box that is a destination rather than a search (gap 0). + for BOX in "6 6 6" "4 6 6" "4 4 4"; do + # shellcheck disable=SC2086 + HINT="$(ref box "$COORD" $BOX | jq -c .tag)" + BAG="$(jq -c --argjson hint "$HINT" '.event | .tags += [$hint]' <<<"$SEALED")" + + GOT="$(printf '%s' "$BAG" | amy cyberspace sweep -)" + FOUND="$(jq -r .found <<<"$GOT")" + GOT_KEY="$(jq -r '.key // ""' <<<"$GOT")" + EXAMINED="$(jq -r .examined <<<"$GOT")" + CANDIDATES="$(jq -r .candidates <<<"$GOT")" + + # A sweep that found it must not have walked past the box it was given. + if [[ "$FOUND" == "true" && "$GOT_KEY" == "$WANT_KEY" && "$EXAMINED" -le "$CANDIDATES" ]]; then + AGREE=$((AGREE+1)) + info "box [$BOX]: found after $EXAMINED of $CANDIDATES candidates" + else + DISAGREE=$((DISAGREE+1)) + fail_msg "box [$BOX]: found=$FOUND key=${GOT_KEY:0:16} want=${WANT_KEY:0:16} examined=$EXAMINED/$CANDIDATES" + fi + done + + # And the refusal §7.7 exists for: a hint is a stranger's choice of + # difficulty, so a box nobody asked to pay for is quoted and declined rather + # than swept. 2^33 keys is hours. + HUGE="$(ref box "$COORD" 15 15 15 | jq -c .tag)" + BAG="$(jq -c --argjson hint "$HUGE" '.event | .tags += [$hint]' <<<"$SEALED")" + if printf '%s' "$BAG" | amy cyberspace sweep - >/dev/null 2>&1; then + DISAGREE=$((DISAGREE+1)) + fail_msg "a gap-33 hint was swept without being asked for" + else + AGREE=$((AGREE+1)) + info "a gap-33 hint is quoted and declined, not swept" + fi + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "cyberspace-sweeps" pass "$AGREE boxes swept as §7.7 describes" + else + record_result "cyberspace-sweeps" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +print_summary + +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 00bc47da2e..8f44e28665 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -89,6 +89,8 @@ in `commonsUI`, under the same package. | `account` | mixed | New-account bootstrap events; the logged-off login/sign-up buttons in `commonsUI` `account/ui/login` and `account/ui/signup`. | | `onchain` | mixed | On-chain zap splitting/broadcasting; user-facing failure strings in `commonsUI` `onchain/ui`. | | `marmot` | mixed | MLS group-chat event processing; group-chat composables (retention picker, agent stream banner) in `commonsUI` `marmot/ui`. | +| `cyberspace` | no | `CYBERSPACE_V2` §7.7 region-bag search: the free quote off a bag's `hint`/`h` tags, the device-measured budget, and the cold sweep flow over quartz's `RegionSweep`. The protocol itself (coordinates, Cantor trees, keys, the bag) is `quartz/.../cyberspace`. | +| `sno` | mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in `commonsUI` under `sno` and `sno/ui`. | | `nip53LiveActivities` | mixed | Live-activity zapper aggregation (logic) + the stream card in `nip53LiveActivities/ui`. | | `search` | no | Event search filtering/ranking, kind registry. | | `preview` | no | OpenGraph / meta-tag link-preview parsing. | diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt new file mode 100644 index 0000000000..dea1a42a27 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt @@ -0,0 +1,272 @@ +/* + * 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.commons.cyberspace + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceHint +import com.vitorpamplona.quartz.cyberspace.RegionSweep +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.TimeSource + +/** + * What a bag's `hint` tag promises, before anything is spent on it. + * + * The whole quote is arithmetic on two tags — §7.7's three heights and §8.6's + * `h` — so a card can carry it without building a single Cantor tree. That is + * deliberate: a feed may scroll past a bag whose hider chose a gap of 75, and + * the reader has to be able to say "out of reach" without having taken a + * stranger's dare first. + */ +@Immutable +sealed class BagSweepQuote { + /** A search this reader is willing to offer, and how big it is. */ + @Immutable + data class Searchable( + val gapBits: Int, + val candidates: Long, + /** + * §7.7: three heights equal to the bag's "name the region itself: the + * hint is then a destination the seeker can compute or walk to + * directly, not a search". + * + * It is still a tap. A reader who learns that some bags open themselves + * has learned something a hostile bag can hide inside, so the one + * candidate is offered exactly like the million. + */ + val destination: Boolean, + ) : BagSweepQuote() + + /** + * A hint whose box is past what this client sweeps, with the gap that + * decided it — so the card can say how far past, rather than only that it + * is. §7.7's own table runs from "seconds" to "days to never", and the far + * end is where a sector-only hint on a shallow bag lands: a gap of 75, + * which nobody will ever sweep. Such a hint "says where to travel, not + * where to search", and Amethyst cannot travel. + */ + @Immutable + data class OutOfReach( + val gapBits: Int, + ) : BagSweepQuote() + + /** + * Nothing to search from: no `hint` tag, a malformed one (§7.7 says those + * are the same thing), or no `d` tag to recognise the region by. + * + * Not an error in the bag. It is content hidden the hard way, and §7.7 is + * clear about what that means: "any given bag is equally likely to be at + * any point in the full 2^256 coordinate space". + */ + @Immutable + data object Hidden : BagSweepQuote() +} + +/** Where a sweep has got to, for a card that has to show progress and a cancel. */ +@Immutable +sealed class BagSweepState { + /** Pricing the first candidate on this device, before committing to the rest. */ + @Immutable + data object Measuring : BagSweepState() + + @Immutable + data class Running( + val examined: Long, + val candidates: Long, + ) : BagSweepState() + + /** + * The region key came up, and the bag opened. + * + * [contents] is null when it did not, which at this point means the + * ciphertext is damaged rather than that the key is wrong — the `lookup_id` + * already matched, and §7.2 makes that a hash of this very key. + */ + @Immutable + data class Opened( + val contents: CyberspaceBagContents?, + ) : BagSweepState() + + /** The whole box was swept and the bag was not in it: the hint was wrong, or it was bait. */ + @Immutable + data class NotFound( + val examined: Long, + ) : BagSweepState() + + /** + * Measured, not guessed: the first candidate cost [millisPerCandidate] on + * *this* device, so the box would take [estimateMillis], which is past what + * this client spends without being asked again. + */ + @Immutable + data class OutOfReach( + val estimateMillis: Long, + val millisPerCandidate: Long, + ) : BagSweepState() +} + +/** + * §7.7's hint-and-sweep, priced for a reader with a battery. + * + * The protocol half is [RegionSweep]; this is the budget around it, and the + * budget is the entire product decision. A hint is a stranger's declaration of + * how hard they want the search to be, so everything here is arranged so the + * reader finds out the price before paying it: + * + * 1. [quote] reads the two tags and costs nothing. A box past [MAX_GAP_BITS] or + * a bag deeper than [MAX_BAG_HEIGHT] is out of reach and never becomes a + * button. + * 2. [sweep] times the first candidate on the device it is actually running on + * before committing to the rest, and stops if that measurement says the box + * is past [BUDGET_MILLIS]. The plan for this feature said to measure the + * Android cost before quoting a number in the UI; measuring it at the moment + * of the tap is the same answer without a constant that goes stale on the + * next handset. + * 3. The sweep is a cold [Flow] over a cold [Sequence], so a cancelled + * collection stops paying immediately. + * + * Nothing here talks to a relay, because the bag is already in hand: this is + * §7.7's search applied to one event someone put in front of you, not a crawl. + * And nothing starts on its own — [sweep] runs when it is collected. + */ +object BagSweep { + /** + * The largest box offered as a button, as §7.7's gap exponent. + * + * 2^20 is about a million candidate regions. §7.7's own table calls 12 + * "seconds" and 24 "hours"; this sits between them, at the scale a phone + * can finish while someone watches. Past it the card says so instead, which + * is the honest answer to a hint that was chosen to be expensive. + */ + const val MAX_GAP_BITS = 20 + + /** + * The deepest bag this offers to search. + * + * Not about the box but about a single key: a region key is three folds of + * integers that double in width at every level, so one candidate at height + * 16 is seconds on its own and one at height 20 is minutes. ONOSENDAI draws + * the same line, capping its own discovery scan at height 12 and selling + * the deeper ones as a service. + */ + const val MAX_BAG_HEIGHT = 12 + + /** + * How long a sweep may be expected to take before it is refused outright. + * + * Two minutes is where §7.7's "seconds" has clearly ended, and it is + * checked against a measurement from this device rather than a table, so a + * slower phone refuses boxes a faster one accepts. That asymmetry is + * correct: the cost is the reader's, so the reader's own hardware decides. + */ + const val BUDGET_MILLIS = 120_000L + + /** How many candidates pass between progress emissions. */ + private const val PROGRESS_EVERY = 64L + + /** + * What this bag's hint promises, from its tags alone. + * + * Costs two subtractions and an addition — safe to call while composing a + * feed row, and deliberately so, because the card has to be able to show + * the price of a search it will not run. + */ + fun quote(bag: CyberspaceBagEvent): BagSweepQuote { + if (!bag.isKnownVersion()) return BagSweepQuote.Hidden + if (bag.lookupId() == null) return BagSweepQuote.Hidden + if (bag.payload() == null) return BagSweepQuote.Hidden + + val height = bag.height() ?: return BagSweepQuote.Hidden + val hint = bag.hint() ?: return BagSweepQuote.Hidden + + val gap = hint.gapBits(height) + if (height > MAX_BAG_HEIGHT || gap > MAX_GAP_BITS) return BagSweepQuote.OutOfReach(gap) + + val candidates = hint.candidates(height) ?: return BagSweepQuote.OutOfReach(gap) + return BagSweepQuote.Searchable(gap, candidates, hint.isDestination(height)) + } + + /** + * Sweep this bag's box, emitting progress, and open it if the key turns up. + * + * Cold: nothing runs until collected, and cancelling the collection stops + * the work at the next candidate. Collect it off the main thread — the + * arithmetic is arbitrary-precision and the whole point is that it is slow. + */ + fun sweep(bag: CyberspaceBagEvent): Flow = + flow { + val quote = quote(bag) + if (quote !is BagSweepQuote.Searchable) { + // Priced and declined before a tree was built. There is nothing + // to emit that the card did not already know from [quote]. + return@flow + } + + val height = bag.height() ?: return@flow + val hint = bag.hint() ?: return@flow + val target = bag.lookupId() ?: return@flow + + emit(BagSweepState.Measuring) + + // The box's own base region, priced on the way past. A gap-0 hint + // names exactly this one, so the measurement is never wasted work: + // it is the first candidate either way. + val mark = TimeSource.Monotonic.markNow() + val base = RegionSweep.of(CyberspaceHint(hint.base, height, height, height), height).first() + val perCandidate = mark.elapsedNow().inWholeMilliseconds + + if (base.lookupId == target) { + emit(BagSweepState.Opened(bag.open(base.decryptionKey))) + return@flow + } + + // One candidate here is three axis roots and a combine; every + // candidate after it is one combine, because §4.7's axes are reused + // across the box. So this over-quotes — by about four times at the + // shallow heights and two at the deep ones — and over-quoting is + // the safe direction for a number whose only job is to decide + // whether to spend somebody's battery. + val estimate = perCandidate * quote.candidates + if (estimate > BUDGET_MILLIS) { + emit(BagSweepState.OutOfReach(estimate, perCandidate)) + return@flow + } + + var examined = 0L + emit(BagSweepState.Running(examined, quote.candidates)) + + for (material in RegionSweep.of(hint, height)) { + currentCoroutineContext().ensureActive() + examined++ + if (material.lookupId == target) { + emit(BagSweepState.Opened(bag.open(material.decryptionKey))) + return@flow + } + if (examined % PROGRESS_EVERY == 0L) emit(BagSweepState.Running(examined, quote.candidates)) + } + + emit(BagSweepState.NotFound(examined)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index c9e15d642c..67d5dc3186 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -139,6 +139,10 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +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 @@ -3836,6 +3840,9 @@ open class EventCache : is GeocacheListingEvent, is GeocacheCurationListEvent, is GeohashListEvent, + is SnoObjectEvent, + is SnoAvatarEvent, + is CyberspaceBagEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, is UserGraspListEvent, @@ -3964,6 +3971,7 @@ open class EventCache : is GitPullRequestEvent, is GitPullRequestUpdateEvent, is GitStatusEvent, + is SnoShardEvent, is ChessGameEvent, is JesterEvent, is HighlightEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt new file mode 100644 index 0000000000..26ea69a8b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload + +/** + * The shape an avatar has when its author has not given it one. + * + * `CYBERSPACE_V2.md` §8.10 makes a `kind 11333` with empty content the default + * avatar: it carries no geometry, owes no proof of work, and is what everyone + * starts as. Drawing nothing for it is technically correct and tells a reader + * nothing — the note simply vanishes from the feed, which reads as a bug. The + * reference puts a wireframe icosahedron in its place wherever an avatar is + * drawn (`scene/AvatarShape.tsx`), so this is that icosahedron, built as a + * payload so it goes through the same renderer as everything else and needs no + * second drawing path. + * + * **Not an event and not from the wire.** Nothing here was published by + * anybody, so §1.2's obligation to keep the lattice exact does not apply to how + * it was derived: the twelve corners of an icosahedron are `(0, ±1, ±φ)` + * and its rotations, `φ` is irrational, and 194 ticks is the nearest the + * lattice comes to `φ` units. What §1.2 does still govern is everything + * downstream, and once these are ticks they are as exact as any other object's. + * + * `lines`, because the reference draws the edges rather than the solid: a + * placeholder should not look like something somebody made. + */ +object SnoDefaultAvatar { + /** One unit, in ticks — the icosahedron's short radius. */ + private const val ONE = SnoPayload.TICKS_PER_UNIT + + /** The golden ratio in ticks, to the nearest one: `1.618034 * 120`. */ + private const val PHI = 194 + + private const val WHITE = 0xFFFFFFFF.toInt() + + val payload: SnoPayload by lazy { build() } + + private fun build(): SnoPayload { + // The three mutually perpendicular golden rectangles whose corners are + // an icosahedron: (0, ±1, ±φ) and its two cyclic rotations. + val positions = + intArrayOf( + 0, + ONE, + PHI, + 0, + ONE, + -PHI, + 0, + -ONE, + PHI, + 0, + -ONE, + -PHI, + ONE, + PHI, + 0, + ONE, + -PHI, + 0, + -ONE, + PHI, + 0, + -ONE, + -PHI, + 0, + PHI, + 0, + ONE, + -PHI, + 0, + ONE, + PHI, + 0, + -ONE, + -PHI, + 0, + -ONE, + ) + // The twenty faces: every triple of corners that are one edge apart, + // each wound so its normal points out of the solid. Derived rather than + // remembered — a hand-written icosahedron comes out looking almost + // right, and the test next door is what says whether it did. + val faces = + intArrayOf( + 0, + 2, + 8, + 0, + 9, + 2, + 0, + 4, + 6, + 0, + 8, + 4, + 0, + 6, + 9, + 1, + 10, + 3, + 1, + 3, + 11, + 1, + 6, + 4, + 1, + 4, + 10, + 1, + 11, + 6, + 2, + 7, + 5, + 2, + 5, + 8, + 2, + 9, + 7, + 3, + 5, + 7, + 3, + 10, + 5, + 3, + 7, + 11, + 4, + 8, + 10, + 5, + 10, + 8, + 6, + 11, + 9, + 7, + 9, + 11, + ) + return SnoPayload( + version = 2, + name = "", + // One gibson: the cell an avatar occupies, which is what the + // reference draws it at. + unit = 0, + extent = 2, + mode = SnoMode.LINES, + positions = positions, + colors = IntArray(positions.size / 3) { WHITE }, + faces = faces, + faceColors = null, + paletteRef = SnoPaletteRef.BuiltIn, + up = false, + spin = 0, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt new file mode 100644 index 0000000000..3dc359d546 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt @@ -0,0 +1,450 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.math.abs +import kotlin.math.sqrt + +/** + * A copy of an object's faces wound so that every one turns its outside out, + * plus which of them are buried inside a join. + * + * Nothing the wire carries says which way round a face is: DECK-0003 §1.4 makes + * winding order meaningless and §4 draws a face two-sided, so an object whose + * triangles disagree is indistinguishable from one whose triangles agree — as + * long as nothing is lit. The moment a reader lights the object (§4 permits it, + * "and many will") the disagreement becomes the whole picture: half the faces of + * a stamped block wind inward, and a lit block with half its faces dark is not a + * block. So lighting needs this pass first. + * + * The method is the one `sno-core`'s `orient.ts` arrives at (MIT; ported, not + * copied), and it is in two halves because one is not enough: + * + * 1. **Faces that share a clean edge are made to agree.** Two consistently wound + * faces run their shared edge in opposite directions, so a face reached + * across an edge it runs the *same* way is flipped. That groups the faces + * into patches without deciding which way a patch faces. An edge carrying + * three or more faces is where solids touch and carries no agreement across + * it — passing agreement through one turns a whole side of a shape inward, + * depending only on which face the walk happened to reach first. + * 2. **Each patch is then turned as a whole by what its faces can see.** A ray + * from a face's middle along its normal crosses the rest of the object an + * even number of times when the normal points out of a closed solid and an + * odd number when it points in. The patch goes whichever way most of its + * area votes; a patch whose rays cross nothing — a flat plate, an open + * shell — falls back to its signed volume, and failing that to facing up. + * + * A face whose rays cross an odd number of faces *both* ways is inside the + * solid: the square between a block stacked on a block. It is reported in + * [interior] so a lit drawing can leave it out, where it would only fight the + * face it sits against. + * + * Positions are read straight from the integer lattice into floats here, which + * is the one conversion §1.2 allows, and the object is never mirrored on the + * way, so the handedness this decides is the handedness the rasteriser draws. + */ +class SnoFaceWinding( + /** Three vertex indices per face, rewound; the payload's own array is untouched. */ + val faces: IntArray, + /** One flag per face: true when it sits inside the solid with faces on both sides. */ + val interior: BooleanArray, +) { + companion object { + /** Below this a triangle has no area and its normal is noise. */ + private const val EPSILON = 1e-9f + + /** A ray that meets a face nearer than this started on it. */ + private const val SELF = 1e-5f + + /** + * How small a signed volume has to be, against the patch's area times + * its reach, before the patch counts as open rather than closed. + */ + private const val FLAT = 1e-3f + + /** + * A slant added to every ray, so that on the axis-aligned shapes an + * author actually builds a ray crosses faces rather than running down + * the edge between two of them, where a hit is a coin toss. + */ + private const val SLANT_X = 0.0173f + private const val SLANT_Y = 0.0311f + private const val SLANT_Z = 0.0237f + + fun of(payload: SnoPayload): SnoFaceWinding { + val faceCount = payload.faceCount + val faces = payload.faces.copyOf() + val interior = BooleanArray(faceCount) + if (faceCount == 0) return SnoFaceWinding(faces, interior) + + val points = FloatArray(payload.vertexCount * 3) + for (i in points.indices) points[i] = payload.positions[i].toFloat() + + val normals = FloatArray(faceCount * 3) + val areas = FloatArray(faceCount) + val forward = IntArray(faceCount) + val backward = IntArray(faceCount) + + for (face in 0 until faceCount) { + measure(points, faces, face, normals, areas) + val dx = normals[face * 3] + SLANT_X + val dy = normals[face * 3 + 1] + SLANT_Y + val dz = normals[face * 3 + 2] + SLANT_Z + val mx = middle(points, faces, face, 0) + val my = middle(points, faces, face, 1) + val mz = middle(points, faces, face, 2) + forward[face] = crossings(points, faces, faceCount, face, mx, my, mz, dx, dy, dz) + backward[face] = crossings(points, faces, faceCount, face, mx, my, mz, -dx, -dy, -dz) + interior[face] = forward[face] % 2 == 1 && backward[face] % 2 == 1 + } + + turnPatches(points, payload.faces, faces, faceCount, areas, forward, backward, interior) + return SnoFaceWinding(faces, interior) + } + + /** The face's unit normal into [normals] and half its length into [areas]. */ + private fun measure( + points: FloatArray, + faces: IntArray, + face: Int, + normals: FloatArray, + areas: FloatArray, + ) { + val a = faces[face * 3] * 3 + val b = faces[face * 3 + 1] * 3 + val c = faces[face * 3 + 2] * 3 + val abx = points[b] - points[a] + val aby = points[b + 1] - points[a + 1] + val abz = points[b + 2] - points[a + 2] + val acx = points[c] - points[a] + val acy = points[c + 1] - points[a + 1] + val acz = points[c + 2] - points[a + 2] + val nx = aby * acz - abz * acy + val ny = abz * acx - abx * acz + val nz = abx * acy - aby * acx + val length = sqrt(nx * nx + ny * ny + nz * nz) + areas[face] = length * 0.5f + if (length > 0f) { + normals[face * 3] = nx / length + normals[face * 3 + 1] = ny / length + normals[face * 3 + 2] = nz / length + } else { + normals[face * 3 + 1] = 1f + } + } + + private fun middle( + points: FloatArray, + faces: IntArray, + face: Int, + axis: Int, + ): Float = + ( + points[faces[face * 3] * 3 + axis] + + points[faces[face * 3 + 1] * 3 + axis] + + points[faces[face * 3 + 2] * 3 + axis] + ) / 3f + + /** How many other faces a ray from this point along this direction crosses. */ + @Suppress("detekt.LongParameterList") + private fun crossings( + points: FloatArray, + faces: IntArray, + faceCount: Int, + skip: Int, + ox: Float, + oy: Float, + oz: Float, + dx: Float, + dy: Float, + dz: Float, + ): Int { + var hits = 0 + for (face in 0 until faceCount) { + if (face == skip) continue + if (hit(points, faces, face, ox, oy, oz, dx, dy, dz)) hits++ + } + return hits + } + + /** + * Möller–Trumbore: whether the ray meets this face ahead of its start. + */ + @Suppress("detekt.LongParameterList") + private fun hit( + points: FloatArray, + faces: IntArray, + face: Int, + ox: Float, + oy: Float, + oz: Float, + dx: Float, + dy: Float, + dz: Float, + ): Boolean { + val a = faces[face * 3] * 3 + val b = faces[face * 3 + 1] * 3 + val c = faces[face * 3 + 2] * 3 + val e1x = points[b] - points[a] + val e1y = points[b + 1] - points[a + 1] + val e1z = points[b + 2] - points[a + 2] + val e2x = points[c] - points[a] + val e2y = points[c + 1] - points[a + 1] + val e2z = points[c + 2] - points[a + 2] + + val px = dy * e2z - dz * e2y + val py = dz * e2x - dx * e2z + val pz = dx * e2y - dy * e2x + val determinant = e1x * px + e1y * py + e1z * pz + if (abs(determinant) < EPSILON) return false + val inverse = 1f / determinant + + val tx = ox - points[a] + val ty = oy - points[a + 1] + val tz = oz - points[a + 2] + val u = (tx * px + ty * py + tz * pz) * inverse + if (u < 0f || u > 1f) return false + + val qx = ty * e1z - tz * e1y + val qy = tz * e1x - tx * e1z + val qz = tx * e1y - ty * e1x + val v = (dx * qx + dy * qy + dz * qz) * inverse + if (v < 0f || u + v > 1f) return false + + return (e2x * qx + e2y * qy + e2z * qz) * inverse > SELF + } + + /** + * Group the faces into patches across their clean edges, then turn each + * patch as a whole. + */ + @Suppress("detekt.LongParameterList") + private fun turnPatches( + points: FloatArray, + original: IntArray, + faces: IntArray, + faceCount: Int, + areas: FloatArray, + forward: IntArray, + backward: IntArray, + interior: BooleanArray, + ) { + val byEdge = HashMap>(faceCount * 3) + for (face in 0 until faceCount) { + forEachEdge(faces, face) { from, to -> + byEdge.getOrPut(edgeKey(from, to)) { ArrayList(2) }.add(face) + } + } + + val seen = BooleanArray(faceCount) + val patch = ArrayList() + val queue = ArrayDeque() + for (start in 0 until faceCount) { + if (seen[start]) continue + seen[start] = true + patch.clear() + queue.clear() + queue.addLast(start) + while (queue.isNotEmpty()) { + val face = queue.removeFirst() + patch.add(face) + forEachEdge(faces, face) { from, to -> + val on = byEdge[edgeKey(from, to)] + // Only an edge with exactly two faces on it carries + // agreement; three or more is where solids touch. + if (on != null && on.size == 2) { + for (other in on) { + if (other == face || seen[other]) continue + // Two faces that agree run a shared edge in + // opposite directions, so one that runs it the + // same way is turned over. + if (runsEdge(faces, other, from, to)) flip(faces, other) + seen[other] = true + queue.addLast(other) + } + } + } + } + if (facesInward(points, original, faces, patch, areas, forward, backward, interior)) { + for (face in patch) flip(faces, face) + } + } + } + + /** + * Whether this patch, as the walk left it, has its back to the outside. + * + * A face's crossings were counted as it was wound to begin with, so a + * face the walk turned over votes the other way from how it was + * counted; the votes are weighted by area, since a patch is decided by + * most of its surface rather than most of its triangles. + */ + @Suppress("detekt.LongParameterList") + private fun facesInward( + points: FloatArray, + original: IntArray, + faces: IntArray, + patch: List, + areas: FloatArray, + forward: IntArray, + backward: IntArray, + interior: BooleanArray, + ): Boolean { + var vote = 0f + for (face in patch) { + if (interior[face]) continue + val turned = if (runsEdge(faces, face, original[face * 3], original[face * 3 + 1])) 1f else -1f + val outward = + if (forward[face] % 2 == 0 && backward[face] % 2 == 1) { + 1f + } else if (forward[face] % 2 == 1 && backward[face] % 2 == 0) { + -1f + } else { + 0f + } + vote += turned * outward * areas[face] + } + if (vote != 0f) return vote < 0f + + // Nothing to see: closed by its own volume, or flat and facing up. + // + // The volume is summed about the patch's own middle rather than + // about the origin. A lattice runs to 7680 ticks out and the terms + // of this sum are cubes of that, so a flat plate far from the + // origin leaves a cancellation residue in a float larger than any + // absolute threshold could tell from a real volume; measured about + // the middle, the terms are the size of the patch and the residue + // goes with them. A closed surface's signed volume does not depend + // on where it is measured from, so nothing is given up. + var middleX = 0f + var middleY = 0f + var middleZ = 0f + for (face in patch) { + for (corner in 0..2) { + val v = faces[face * 3 + corner] * 3 + middleX += points[v] + middleY += points[v + 1] + middleZ += points[v + 2] + } + } + val corners = patch.size * 3 + middleX /= corners + middleY /= corners + middleZ /= corners + + var volume = 0f + var nx = 0f + var ny = 0f + var nz = 0f + var reach = 0f + var twiceArea = 0f + for (face in patch) { + val a = faces[face * 3] * 3 + val b = faces[face * 3 + 1] * 3 + val c = faces[face * 3 + 2] * 3 + val aX = points[a] - middleX + val aY = points[a + 1] - middleY + val aZ = points[a + 2] - middleZ + val bX = points[b] - middleX + val bY = points[b + 1] - middleY + val bZ = points[b + 2] - middleZ + val cX = points[c] - middleX + val cY = points[c + 1] - middleY + val cZ = points[c + 2] - middleZ + volume += (aX * (bY * cZ - bZ * cY) - aY * (bX * cZ - bZ * cX) + aZ * (bX * cY - bY * cX)) / 6f + val abx = bX - aX + val aby = bY - aY + val abz = bZ - aZ + val acx = cX - aX + val acy = cY - aY + val acz = cZ - aZ + val fx = aby * acz - abz * acy + val fy = abz * acx - abx * acz + val fz = abx * acy - aby * acx + nx += fx + ny += fy + nz += fz + twiceArea += sqrt(fx * fx + fy * fy + fz * fz) + reach = maxOf(reach, abs(aX), abs(aY), abs(aZ)) + } + // A real volume is the patch's area times its reach; what survives + // the cancellation in an open one is that times float's own + // precision. The threshold sits between the two, far from both. + if (abs(volume) > FLAT * twiceArea * reach) return volume < 0f + val ax = abs(nx) + val ay = abs(ny) + val az = abs(nz) + return if (ay >= ax && ay >= az) { + ny < 0f + } else if (ax >= az) { + nx < 0f + } else { + nz < 0f + } + } + + private inline fun forEachEdge( + faces: IntArray, + face: Int, + action: (Int, Int) -> Unit, + ) { + val a = faces[face * 3] + val b = faces[face * 3 + 1] + val c = faces[face * 3 + 2] + action(a, b) + action(b, c) + action(c, a) + } + + /** Whether this face runs the edge from -> to in that direction. */ + private fun runsEdge( + faces: IntArray, + face: Int, + from: Int, + to: Int, + ): Boolean { + val a = faces[face * 3] + val b = faces[face * 3 + 1] + val c = faces[face * 3 + 2] + return (a == from && b == to) || (b == from && c == to) || (c == from && a == to) + } + + private fun flip( + faces: IntArray, + face: Int, + ) { + val swap = faces[face * 3 + 1] + faces[face * 3 + 1] = faces[face * 3 + 2] + faces[face * 3 + 2] = swap + } + + /** An undirected edge as one number; vertex indices are below 512. */ + private fun edgeKey( + a: Int, + b: Int, + ): Long { + val low = if (a < b) a else b + val high = if (a < b) b else a + return (low.toLong() shl 32) or high.toLong() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt new file mode 100644 index 0000000000..06b7be637c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt @@ -0,0 +1,149 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.math.sqrt + +/** + * How much light falls on each side of each face of an object, for the + * inspection drawing. + * + * DECK-0003 §4 makes **unlit** the default reading of an SNO and that is what a + * feed shows, so an object looks the same everywhere it is quoted. The same + * section then says a client "MAY light an object instead, and many will, + * because a lit object sits better in a lit scene", on two conditions: normals + * are per face and flat, from the triangle's own vertices, and no reader + * synthesises smooth ones by averaging across a shared vertex. Both are met + * here — there is one number per face and it comes from that face's cross + * product — and no vertex moves, so §4's "MUST NOT invent geometry" is intact. + * + * This is the shading the reference workshop's bench uses while a shard is + * being built, and it exists for the same reason: unlit, a cube of one colour + * is a flat hexagon, and an object you are turning over to understand is the + * one case where the light is the information. Two things make that legible: + * + * - **Every face is wound outward first** ([SnoFaceWinding]), because half the + * triangles of a stamped block wind inward and lighting them by the normal + * they arrived with would make a solid block half black. + * - **The inside of a face is drawn darker** ([INSIDE]), lit by its own reversed + * normal, so an open shape shows that it is open and a missing face reads as + * a hole rather than as a slightly odd colour. + * + * Nothing here depends on the viewing angle: the lights are fixed to the object + * the way the bench's are fixed in its world, so turning the object moves the + * light across it — which is the whole point — and the shade of a given face is + * computed once and reused for every frame of a turn. + */ +class SnoLighting( + /** Three vertex indices per face, wound outward. */ + val faces: IntArray, + /** Faces buried inside a join, which the drawing leaves out. */ + val interior: BooleanArray, + /** What to multiply a face's colour by when its outside is the side you see. */ + val outside: FloatArray, + /** The same for its inside, already darkened by [INSIDE]. */ + val inside: FloatArray, +) { + companion object { + /** + * The bench's three lights — `ambientLight 0.4`, a key at `0.9` and a + * dim fill from behind at `0.6` — divided by the 1.3 a face square-on + * to the key receives, so that face shows its own colour exactly and + * no face anywhere on the sphere clips to white. The brightest a normal + * can reach with both lights is 0.948, the dimmest 0.308. + */ + private const val AMBIENT = 0.4f / 1.3f + private const val KEY = 0.9f / 1.3f + private const val FILL = 0.6f / 1.3f + + /** + * The reference's two light positions carried over as directions, not + * as coordinates: they are stated in the bench's render space and + * relative to *its* opening camera, so what is reproduced here is where + * they sit relative to the view — the key a little above the line of + * sight and slightly to the right, the fill high behind the left + * shoulder — planted in model space at this renderer's own opening + * angle ([SnoRasterizer.DEFAULT_YAW_DEGREES], `DEFAULT_PITCH_DEGREES`). + * Copying the raw positions instead would have put the key behind the + * object, because the two viewers do not open on the same side of it. + */ + private val KEY_DIRECTION = floatArrayOf(-0.3667f, 0.0175f, 0.9302f) + private val FILL_DIRECTION = floatArrayOf(-0.2573f, 0.7696f, -0.5844f) + + /** + * How much of its colour a face keeps when you are looking at its + * inside: the `#6a6a6a` the reference multiplies the inside mesh by. + */ + private const val INSIDE = 0x6A / 255f + + fun of(payload: SnoPayload): SnoLighting { + val winding = SnoFaceWinding.of(payload) + val faceCount = payload.faceCount + val outside = FloatArray(faceCount) + val inside = FloatArray(faceCount) + + for (face in 0 until faceCount) { + val a = winding.faces[face * 3] * 3 + val b = winding.faces[face * 3 + 1] * 3 + val c = winding.faces[face * 3 + 2] * 3 + // The one conversion from the exact lattice to floats (§1.2), + // and flat per face as §4 requires: this triangle's own corners + // and nothing averaged in from a neighbour. + val abx = (payload.positions[b] - payload.positions[a]).toFloat() + val aby = (payload.positions[b + 1] - payload.positions[a + 1]).toFloat() + val abz = (payload.positions[b + 2] - payload.positions[a + 2]).toFloat() + val acx = (payload.positions[c] - payload.positions[a]).toFloat() + val acy = (payload.positions[c + 1] - payload.positions[a + 1]).toFloat() + val acz = (payload.positions[c + 2] - payload.positions[a + 2]).toFloat() + + var nx = aby * acz - abz * acy + var ny = abz * acx - abx * acz + var nz = abx * acy - aby * acx + val length = sqrt(nx * nx + ny * ny + nz * nz) + if (length > 0f) { + nx /= length + ny /= length + nz /= length + } else { + ny = 1f + } + + outside[face] = shade(nx, ny, nz) + inside[face] = shade(-nx, -ny, -nz) * INSIDE + } + + return SnoLighting(winding.faces, winding.interior, outside, inside) + } + + /** Lambert: the ambient, plus each light by how squarely the face meets it. */ + private fun shade( + nx: Float, + ny: Float, + nz: Float, + ): Float { + val key = nx * KEY_DIRECTION[0] + ny * KEY_DIRECTION[1] + nz * KEY_DIRECTION[2] + val fill = nx * FILL_DIRECTION[0] + ny * FILL_DIRECTION[1] + nz * FILL_DIRECTION[2] + val light = AMBIENT + KEY * maxOf(key, 0f) + FILL * maxOf(fill, 0f) + return if (light > 1f) 1f else light + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt new file mode 100644 index 0000000000..88a80b5275 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt @@ -0,0 +1,60 @@ +/* + * 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.commons.sno + +/** + * The pixel and depth buffers of a view that draws the same object over and over. + * + * [SnoRasterizer.render] needs a pixel buffer and a depth buffer the size of the + * raster, and on its own it allocates both every call. That is the right thing + * for a thumbnail, which draws once. It is the wrong thing for a turn: at 384px + * the two come to 1.2 MB, a drag asks for about thirty-five frames a second, and + * on an SM-T220 that 41 MB/s of garbage took 51% of a core and stuttered the + * turn every few seconds — while the same screen, held still, spent none. + * + * A viewer rasters at one size for the whole gesture, so it can hand the same + * two buffers back on every frame and allocate nothing. The buffers grow to + * whatever size is asked for and are kept until it changes. + * + * **Handed out, not copied, so one holder must never serve two renders at once.** + * A caller that can overlap its frames has to serialise them; `SnoObjectViewer` + * conflates its angles through a single collector for exactly this reason. Where + * a render stands alone, pass nothing and let it allocate. + */ +class SnoRasterScratch { + private var pixels: IntArray = EMPTY_PIXELS + private var depth: FloatArray = EMPTY_DEPTH + + internal fun pixels(size: Int): IntArray { + if (pixels.size != size) pixels = IntArray(size) + return pixels + } + + internal fun depth(size: Int): FloatArray { + if (depth.size != size) depth = FloatArray(size) + return depth + } + + private companion object { + private val EMPTY_PIXELS = IntArray(0) + private val EMPTY_DEPTH = FloatArray(0) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt new file mode 100644 index 0000000000..0b093853e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt @@ -0,0 +1,705 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Draws a Simple Nostr Object into a pixel buffer, on the CPU. + * + * Software rather than `Canvas.drawVertices`, which is common Compose API and + * would give interpolated triangles for free, because that call is only + * hardware-accelerated from API 29 and Amethyst's `minSdk` is 26 — on Android 8 + * and 9 it silently draws nothing. Doing it here also buys a real depth buffer, + * which a painter's-algorithm fallback does not have and which lattice geometry + * needs the moment two objects are authored to share an edge, and it makes the + * whole renderer testable headless, with no device and no screenshot harness. + * + * The shading is what DECK-0003 §4 decides, so that the same object does not + * look like two objects: + * - **Unlit.** A face takes its colour by interpolating its three vertices and + * no light in the scene changes it. This is `KHR_materials_unlit` with a + * `COLOR_0` attribute, which is the one-sentence bridge to anyone in glTF. + * - **Two-sided.** A face has no front and no back: winding order carries no + * meaning and nothing is culled by it. + * - **A face that carries its own colour is filled with it flatly**, and there + * is nothing to interpolate or average: the seam between two such faces is + * exact, which is the whole purpose of `facecolors`. + * - **No invented geometry.** No subdivision, no smoothing that moves a vertex, + * no hole filling, and no synthesised smooth normals. The lattice is exact and + * a renderer that moves a vertex has broken the one guarantee it makes. + * + * §4 also permits a client to light an object instead, which is what the + * `lighting` argument does; see [SnoLighting] for what that changes and why it + * is offered only where a reader is inspecting one object rather than + * scrolling past many. + * + * Positions arrive as exact integers and are converted to floats here, at the + * boundary, and nowhere earlier (§1.2). + */ +object SnoRasterizer { + /** A three-quarter view, which is what shows a small object's shape best. */ + const val DEFAULT_YAW_DEGREES = 30f + const val DEFAULT_PITCH_DEGREES = -20f + + /** Fraction of the shorter side left empty around the object. */ + private const val MARGIN = 0.08f + + /** + * Below this many pixels across, an object is too small for a triangle to + * land, and its vertices go back to being the whole drawing (§1.5). + */ + private const val TOO_SMALL_FOR_TRIANGLES = 3f + + /** + * §1.5: "A client SHOULD also draw the vertices as points in every mode, so + * that an object remains visible when it is smaller on screen than a + * triangle." The reference viewer keeps two profiles for that and so does + * this one (ONOSENDAI `scene/pointDisc.ts`): where the vertices *are* the + * shape they carry its size, and under a solid or a wireframe they are a + * hint that they are there and must never become a second shape competing + * with the faces. Each diameter is a length in model units, clamped in + * pixels at both ends, so a vertex is dust on a large object and still a + * dot on a tiny one. + */ + private const val SHAPE_POINT_UNITS = 0.3f + private const val SHAPE_POINT_MIN_PX = 1.6f + private const val SHAPE_POINT_MAX_PX = 10f + private const val HINT_DOT_UNITS = 0.07f + private const val HINT_DOT_MIN_PX = 0.7f + private const val HINT_DOT_MAX_PX = 2.4f + + /** How solid a hint dot is over the face it sits on. */ + private const val HINT_DOT_ALPHA = 0.55f + + /** + * Ticks of slack a vertex dot gets against the depth buffer. + * + * A vertex lies exactly on the surface of every face that meets there, and + * whether the triangle's interpolated depth at that pixel lands a hair in + * front of the vertex or a hair behind it is a matter of float rounding. A + * 240th of a unit of bias settles it the only way that is ever wanted, and + * is far below a pixel at any raster size this draws at. + */ + private const val DOT_DEPTH_BIAS = 0.5f + + private const val TRANSPARENT = 0 + + /** Below this a triangle has no area to fill and its reciprocal is noise. */ + private const val MIN_AREA = 1e-6f + + /** Light on a face when the object is drawn unlit: all of it, unchanged. */ + private const val UNLIT = 1f + + /** + * @param background an opaque ARGB fill, or 0 for a transparent buffer. + * @param lighting the light falling on each face, which also carries the + * faces wound outward; `null` for §4's default unlit reading. + * @param scratch buffers to draw into instead of allocating a pair, for a + * caller that draws the same object many times over; see [SnoRasterScratch] + * for why a turn wants this and why one holder serves one render at a time. + * With a scratch the returned array is the holder's own and is overwritten + * by the next render, so a caller that keeps the pixels must copy them — + * both `PlatformImage.create` implementations do. + * @return `width * height` ARGB pixels, row-major. + */ + @Suppress("detekt.LongParameterList") + fun render( + payload: SnoPayload, + width: Int, + height: Int, + yawDegrees: Float = DEFAULT_YAW_DEGREES, + pitchDegrees: Float = DEFAULT_PITCH_DEGREES, + background: Int = TRANSPARENT, + lighting: SnoLighting? = null, + scratch: SnoRasterScratch? = null, + ): IntArray { + require(width > 0 && height > 0) { "a raster needs a positive size" } + + val area = width * height + // A fresh array is cheaper to fill than a reused one — allocation hands + // back zeroed pages — so the lambda form stays where nothing is reused. + val pixels = scratch?.pixels(area)?.also { it.fill(background) } ?: IntArray(area) { background } + if (payload.vertexCount == 0) return pixels + + val depth = + scratch?.depth(area)?.also { it.fill(Float.NEGATIVE_INFINITY) } + ?: FloatArray(area) { Float.NEGATIVE_INFINITY } + val screen = project(payload, width, height, yawDegrees, pitchDegrees) + + val drawTriangles = payload.mode == SnoMode.SOLID && payload.faceCount > 0 + if (drawTriangles) { + drawFaces(payload, screen, pixels, depth, width, height, lighting) + } + val drawLines = payload.mode == SnoMode.LINES && payload.vertexCount > 1 + if (drawLines) { + drawEdges(payload, screen, pixels, depth, width, height) + } + + // The vertices, in every mode (§1.5). They are the drawing itself + // wherever nothing else got drawn — `points` mode, a `solid` with no + // faces, a `lines` with a single vertex — and also when what was drawn + // came out too small for a triangle to land on a pixel. Everywhere else + // they are the hint. + val pointsAreTheShape = !(drawTriangles || drawLines) || screen.spanPixels < TOO_SMALL_FOR_TRIANGLES + drawPoints(payload, screen, pixels, depth, width, height, pointsAreTheShape) + + return pixels + } + + /** + * Every vertex in pixel space, plus how much of the raster the object fills. + * + * Orthographic, looking down `-Z`: `+X` is right, `+Y` is up and `+Z` is + * toward the viewer (§2), so a larger Z is nearer and screen Y runs the + * other way from model Y. A thumbnail wants no perspective distortion and + * no near plane to clip against. + */ + private fun project( + payload: SnoPayload, + width: Int, + height: Int, + yawDegrees: Float, + pitchDegrees: Float, + ): Projection { + val count = payload.vertexCount + val xs = FloatArray(count) + val ys = FloatArray(count) + val zs = FloatArray(count) + + val yaw = yawDegrees * DEG_TO_RAD + val pitch = pitchDegrees * DEG_TO_RAD + val cosYaw = cos(yaw) + val sinYaw = sin(yaw) + val cosPitch = cos(pitch) + val sinPitch = sin(pitch) + + var minX = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + + for (i in 0 until count) { + // The one conversion from the exact lattice to floats. + val x = payload.tickAt(i, 0).toFloat() + val y = payload.tickAt(i, 1).toFloat() + val z = payload.tickAt(i, 2).toFloat() + + val x1 = x * cosYaw + z * sinYaw + val z1 = -x * sinYaw + z * cosYaw + val y2 = y * cosPitch - z1 * sinPitch + val z2 = y * sinPitch + z1 * cosPitch + + xs[i] = x1 + ys[i] = y2 + zs[i] = z2 + + if (x1 < minX) minX = x1 + if (x1 > maxX) maxX = x1 + if (y2 < minY) minY = y2 + if (y2 > maxY) maxY = y2 + } + + val spanX = maxX - minX + val spanY = maxY - minY + val usable = min(width, height) * (1f - 2f * MARGIN) + val largest = max(spanX, spanY) + val scale = if (largest <= 0f) 1f else usable / largest + + val centreX = (minX + maxX) * 0.5f + val centreY = (minY + maxY) * 0.5f + val halfWidth = width * 0.5f + val halfHeight = height * 0.5f + + for (i in 0 until count) { + xs[i] = halfWidth + (xs[i] - centreX) * scale + // Screen Y grows downward; model +Y is up. + ys[i] = halfHeight - (ys[i] - centreY) * scale + } + + return Projection(xs, ys, zs, largest * scale, scale * SnoPayload.TICKS_PER_UNIT) + } + + private class Projection( + val xs: FloatArray, + val ys: FloatArray, + val zs: FloatArray, + /** How many pixels across the object turned out to be. */ + val spanPixels: Float, + /** How many pixels one model unit came out as, for sizing the dots. */ + val pixelsPerUnit: Float, + ) + + @Suppress("detekt.LongParameterList") + private fun drawFaces( + payload: SnoPayload, + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + lighting: SnoLighting?, + ) { + val faceColors = payload.faceColors + // Lit, the faces are the ones wound outward, since which side of a face + // you are looking at is the question the light answers. + val faces = lighting?.faces ?: payload.faces + for (face in 0 until payload.faceCount) { + // A face buried inside a join has faces on both sides of it: lit, it + // would only fight the one it sits against. + if (lighting != null && lighting.interior[face]) continue + val a = faces[face * 3] + val b = faces[face * 3 + 1] + val c = faces[face * 3 + 2] + // A face that carries its own colour fills the whole triangle flatly, + // and its vertices contribute nothing to the fill (§1.4a). + val flat = faceColors?.get(face) + fillTriangle( + screen, + pixels, + depth, + width, + height, + a, + b, + c, + flat ?: payload.colors[a], + flat ?: payload.colors[b], + flat ?: payload.colors[c], + lighting?.outside?.get(face) ?: UNLIT, + lighting?.inside?.get(face) ?: UNLIT, + ) + } + } + + /** + * One triangle, barycentric, both sides drawn. + * + * The edge function's sign tells us the winding, and it is used only to + * normalise the barycentric weights — never to decide whether to draw + * (§1.4). + */ + @Suppress("detekt.LongParameterList") + private fun fillTriangle( + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + ia: Int, + ib: Int, + ic: Int, + colorA: Int, + colorB: Int, + colorC: Int, + shadeOutside: Float, + shadeInside: Float, + ) { + val ax = screen.xs[ia] + val ay = screen.ys[ia] + val bx = screen.xs[ib] + val by = screen.ys[ib] + val cx = screen.xs[ic] + val cy = screen.ys[ic] + + val area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (area > -MIN_AREA && area < MIN_AREA) return + + val left = max(0, floorToInt(min(ax, min(bx, cx)))) + val right = min(width - 1, ceilToInt(max(ax, max(bx, cx)))) + val top = max(0, floorToInt(min(ay, min(by, cy)))) + val bottom = min(height - 1, ceilToInt(max(ay, max(by, cy)))) + if (left > right || top > bottom) return + + // Each edge function is linear in x and y, so it is evaluated once at + // the corner of the span and then stepped: three adds a pixel instead + // of six multiplies and four subtractions. The weights still come out + // barycentric, they are just not recomputed from scratch every time. + // + // The sign of the signed area is folded into the coefficients rather + // than tested per pixel, which is also what draws both sides of a face + // (§1.4): a triangle wound the other way has its edge functions + // negated along with its area, so the inside test is the same one. + val flip = if (area < 0f) -1f else 1f + val constA = (bx * cy - by * cx) * flip + val stepAx = (by - cy) * flip + val stepAy = (cx - bx) * flip + val constB = (cx * ay - cy * ax) * flip + val stepBx = (cy - ay) * flip + val stepBy = (ax - cx) * flip + val total = area * flip + val inverseTotal = 1f / total + + // The same sign says which side of the face is turned toward us, once + // the faces have been wound outward: screen Y runs down, so a triangle + // whose outside faces the viewer comes out with a negative signed area. + // Unlit both shades are 1 and the question never arises. + val shade = if (area < 0f) shadeOutside else shadeInside + + // A face whose three corners agree has nothing to interpolate, and that + // is the common case: a stamped block is one colour, and a face that + // carries its own colour (§1.4a) arrives here as all three. Its colour + // under its light is therefore settled once for the whole triangle + // instead of being rebuilt at every pixel. + val interpolate = !(colorA == colorB && colorB == colorC) + val solid = + if (interpolate) { + 0 + } else if (shade == UNLIT) { + colorA + } else { + darken(colorA, shade) + } + + val az = screen.zs[ia] + val bz = screen.zs[ib] + val cz = screen.zs[ic] + + val startX = left + 0.5f + var rowA = constA + startX * stepAx + (top + 0.5f) * stepAy + var rowB = constB + startX * stepBx + (top + 0.5f) * stepBy + + for (py in top..bottom) { + var edgeA = rowA + var edgeB = rowB + var at = py * width + left + for (px in left..right) { + val edgeC = total - edgeA - edgeB + if (edgeA >= 0f && edgeB >= 0f && edgeC >= 0f) { + val wA = edgeA * inverseTotal + val wB = edgeB * inverseTotal + val wC = edgeC * inverseTotal + val z = wA * az + wB * bz + wC * cz + if (z > depth[at]) { + depth[at] = z + pixels[at] = if (interpolate) blend(colorA, colorB, colorC, wA, wB, wC, shade) else solid + } + } + edgeA += stepAx + edgeB += stepBx + at++ + } + rowA += stepAy + rowB += stepBy + } + } + + /** + * The edges of the faces, each drawn once (§1.5); with no faces, one + * polyline through the vertices in order. + */ + private fun drawEdges( + payload: SnoPayload, + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + ) { + if (payload.faceCount == 0) { + for (i in 0 until payload.vertexCount - 1) { + drawLine(screen, pixels, depth, width, height, i, i + 1, payload.colors[i], payload.colors[i + 1]) + } + return + } + + val seen = HashSet(payload.faceCount * 3) + for (face in 0 until payload.faceCount) { + val a = payload.faces[face * 3] + val b = payload.faces[face * 3 + 1] + val c = payload.faces[face * 3 + 2] + edge(seen, a, b)?.let { drawLine(screen, pixels, depth, width, height, a, b, payload.colors[a], payload.colors[b]) } + edge(seen, b, c)?.let { drawLine(screen, pixels, depth, width, height, b, c, payload.colors[b], payload.colors[c]) } + edge(seen, a, c)?.let { drawLine(screen, pixels, depth, width, height, a, c, payload.colors[a], payload.colors[c]) } + } + } + + /** Null when this edge has already been drawn; the key otherwise. */ + private fun edge( + seen: HashSet, + a: Int, + b: Int, + ): Long? { + val low = min(a, b).toLong() + val high = max(a, b).toLong() + val key = (low shl 32) or high + return if (seen.add(key)) key else null + } + + private fun drawLine( + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + from: Int, + to: Int, + colorFrom: Int, + colorTo: Int, + ) { + val x0 = screen.xs[from] + val y0 = screen.ys[from] + val x1 = screen.xs[to] + val y1 = screen.ys[to] + val steps = max(abs(x1 - x0), abs(y1 - y0)).roundToInt() + if (steps <= 0) { + plot(pixels, depth, width, height, x0.roundToInt(), y0.roundToInt(), screen.zs[from], colorFrom) + return + } + val z0 = screen.zs[from] + val z1 = screen.zs[to] + for (step in 0..steps) { + val t = step.toFloat() / steps + plot( + pixels, + depth, + width, + height, + (x0 + (x1 - x0) * t).roundToInt(), + (y0 + (y1 - y0) * t).roundToInt(), + z0 + (z1 - z0) * t, + mix(colorFrom, colorTo, t), + ) + } + } + + /** + * The vertices, as round dots at one of the two sizes §1.5 asks for. + * + * @param asShape true where the dots are the drawing, false where something + * is already drawn and they are the hint laid over it. + */ + @Suppress("detekt.LongParameterList") + private fun drawPoints( + payload: SnoPayload, + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + asShape: Boolean, + ) { + val perUnit = screen.pixelsPerUnit + val diameter = + if (asShape) { + (SHAPE_POINT_UNITS * perUnit).coerceIn(SHAPE_POINT_MIN_PX, SHAPE_POINT_MAX_PX) + } else { + (HINT_DOT_UNITS * perUnit).coerceIn(HINT_DOT_MIN_PX, HINT_DOT_MAX_PX) + } + // A dot thinner than a pixel cannot be drawn any smaller, so it is drawn + // fainter instead, which is how a pixel says "less than one of me". + val alpha = (if (asShape) 1f else HINT_DOT_ALPHA) * min(1f, diameter) + val radius = diameter * 0.5f + val colors = dotColors(payload) + + for (i in 0 until payload.vertexCount) { + drawDot(pixels, depth, width, height, screen.xs[i], screen.ys[i], screen.zs[i], colors[i], radius, alpha) + } + } + + /** + * The colour each vertex is marked in. + * + * Its own, except on a filled object whose faces carry their own colours: + * there §1.4a has already decided that the vertex colours are not what this + * object shows anywhere, and a dot in a colour that appears nowhere else + * would be the format contradicting itself on the reader's screen. The + * reference viewer reaches the same place from the other end — it splits + * every coloured face into three corners of its own before it draws + * anything, so its points inherit the face colour too — and where a vertex + * is shared by faces of different colours it draws one dot per face and the + * last one wins, which is exactly the vertex's last face, as here. + */ + private fun dotColors(payload: SnoPayload): IntArray { + val faceColors = payload.faceColors + if (faceColors == null || payload.mode != SnoMode.SOLID) return payload.colors + val resolved = payload.colors.copyOf() + for (face in 0 until payload.faceCount) { + val color = faceColors[face] + resolved[payload.faces[face * 3]] = color + resolved[payload.faces[face * 3 + 1]] = color + resolved[payload.faces[face * 3 + 2]] = color + } + return resolved + } + + /** + * One round dot, its rim softened by how much of each pixel it covers. + * + * It reads the depth buffer and does not write it, as the reference's point + * material does (`depthWrite: false`): a vertex on the far side of an object + * stays hidden behind the faces in front of it, while two dots that overlap + * both land instead of one clipping the other. [DOT_DEPTH_BIAS] is what + * lets a vertex sitting exactly on the faces that meet there win against + * them. + */ + @Suppress("detekt.LongParameterList") + private fun drawDot( + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + centreX: Float, + centreY: Float, + z: Float, + color: Int, + radius: Float, + alpha: Float, + ) { + val near = z + DOT_DEPTH_BIAS + val reach = radius + 0.5f + val left = max(0, floorToInt(centreX - reach)) + val right = min(width - 1, ceilToInt(centreX + reach)) + val top = max(0, floorToInt(centreY - reach)) + val bottom = min(height - 1, ceilToInt(centreY + reach)) + if (left > right || top > bottom) return + + for (py in top..bottom) { + val dy = py + 0.5f - centreY + var at = py * width + left + for (px in left..right) { + val dx = px + 0.5f - centreX + val coverage = reach - sqrt(dx * dx + dy * dy) + if (coverage > 0f && near >= depth[at]) { + over(pixels, at, color, alpha * min(coverage, 1f)) + } + at++ + } + } + } + + /** + * Source-over compositing of an opaque colour at [alpha] into the + * non-premultiplied ARGB buffer, which may itself be transparent where the + * background is. + */ + private fun over( + pixels: IntArray, + at: Int, + color: Int, + alpha: Float, + ) { + val source = min(alpha, 1f) + if (source <= 0f) return + val destination = pixels[at] + val kept = (destination ushr 24) * (1f / 255f) * (1f - source) + val total = source + kept + if (total <= 0f) return + val inverse = 1f / total + val r = ((color shr 16 and 0xFF) * source + (destination shr 16 and 0xFF) * kept) * inverse + val g = ((color shr 8 and 0xFF) * source + (destination shr 8 and 0xFF) * kept) * inverse + val b = ((color and 0xFF) * source + (destination and 0xFF) * kept) * inverse + pixels[at] = + (clamp255((total * 255f).roundToInt()) shl 24) or + (clamp255(r.roundToInt()) shl 16) or + (clamp255(g.roundToInt()) shl 8) or + clamp255(b.roundToInt()) + } + + private fun plot( + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + x: Int, + y: Int, + z: Float, + color: Int, + ) { + if (x < 0 || y < 0 || x >= width || y >= height) return + val at = y * width + x + if (z < depth[at]) return + depth[at] = z + pixels[at] = color + } + + /** + * Barycentric interpolation between three opaque colours, times the light. + * The three are never all equal here; [fillTriangle] settles that case once + * for the whole triangle. + */ + @Suppress("detekt.LongParameterList") + private fun blend( + a: Int, + b: Int, + c: Int, + wA: Float, + wB: Float, + wC: Float, + shade: Float, + ): Int { + val r = (((a shr 16 and 0xFF) * wA + (b shr 16 and 0xFF) * wB + (c shr 16 and 0xFF) * wC) * shade).roundToInt() + val g = (((a shr 8 and 0xFF) * wA + (b shr 8 and 0xFF) * wB + (c shr 8 and 0xFF) * wC) * shade).roundToInt() + val bl = (((a and 0xFF) * wA + (b and 0xFF) * wB + (c and 0xFF) * wC) * shade).roundToInt() + return (0xFF shl 24) or (clamp255(r) shl 16) or (clamp255(g) shl 8) or clamp255(bl) + } + + /** An opaque colour under a light that is less than all of it. */ + private fun darken( + color: Int, + shade: Float, + ): Int = + (0xFF shl 24) or + (clamp255(((color shr 16 and 0xFF) * shade).roundToInt()) shl 16) or + (clamp255(((color shr 8 and 0xFF) * shade).roundToInt()) shl 8) or + clamp255(((color and 0xFF) * shade).roundToInt()) + + private fun mix( + from: Int, + to: Int, + t: Float, + ): Int { + if (from == to) return from + val r = ((from shr 16 and 0xFF) + ((to shr 16 and 0xFF) - (from shr 16 and 0xFF)) * t).roundToInt() + val g = ((from shr 8 and 0xFF) + ((to shr 8 and 0xFF) - (from shr 8 and 0xFF)) * t).roundToInt() + val b = ((from and 0xFF) + ((to and 0xFF) - (from and 0xFF)) * t).roundToInt() + return (0xFF shl 24) or (clamp255(r) shl 16) or (clamp255(g) shl 8) or clamp255(b) + } + + private fun clamp255(v: Int) = + if (v < 0) { + 0 + } else if (v > 255) { + 255 + } else { + v + } + + private fun floorToInt(v: Float): Int { + val i = v.toInt() + return if (v < 0f && v != i.toFloat()) i - 1 else i + } + + private fun ceilToInt(v: Float): Int { + val i = v.toInt() + return if (v > 0f && v != i.toFloat()) i + 1 else i + } + + private const val DEG_TO_RAD = 0.017453292f +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt new file mode 100644 index 0000000000..ced3d3c4bc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt @@ -0,0 +1,190 @@ +/* + * 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.commons.cyberspace + +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The budget around §7.7's sweep, on a bag the reference implementation sealed. + * + * The payload and the `d` tag below are the london height-4 vector that + * `CyberspaceBagEventTest` pins against `cyberspace-cli`'s own cipher, so a + * sweep that finds it here has reproduced the hider's region key from nothing + * but a box — which is the whole claim §7.7 makes. + */ +class BagSweepTest { + /** `cyberspace-cli`'s AES-256-GCM over a plain text note, at the london height-4 key. */ + private val payload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA==" + + /** The aligned bases of the boxes around that coordinate, from the reference's own interleave. */ + private val box444 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c8000" + private val box666 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e3380000" + private val box121212 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76c000000000" + + private fun bag(vararg extra: Array): CyberspaceBagEvent = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"), + arrayOf("version", "2"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + ) + extra, + content = "", + sig = "0".repeat(128), + ) + + private fun hint( + base: String, + h: Int, + ) = arrayOf("hint", base, h.toString(), h.toString(), h.toString()) + + @Test + fun aBoxIsPricedFromTwoTagsAndNothingElse() { + val quote = BagSweep.quote(bag(hint(box666, 6))) + assertIs(quote) + assertEquals(6, quote.gapBits, "(6-4) three times") + assertEquals(64, quote.candidates) + assertEquals(false, quote.destination) + } + + @Test + fun aHintThatNamesTheRegionIsStillOfferedAsASearch() { + // §7.7 calls three heights equal to the bag's "a destination the seeker + // can compute or walk to directly, not a search". It is still a tap: a + // reader who learned that some bags open themselves would have learned + // an expectation a hostile bag could hide inside. + val quote = BagSweep.quote(bag(hint(box444, 4))) + assertIs(quote) + assertEquals(0, quote.gapBits) + assertEquals(1, quote.candidates) + assertTrue(quote.destination) + } + + @Test + fun aBoxPastTheCapIsNeverOfferedAsAButton() { + // 3 x (12 - 4) = 24, which §7.7's own table calls "hours". + val quote = BagSweep.quote(bag(hint(box121212, 12))) + assertIs(quote) + assertEquals(24, quote.gapBits) + } + + @Test + fun aBagWithNoUsableHintIsHiddenRatherThanBroken() { + // §7.7: a malformed hint "MUST be treated as absent", and §7.6 says a + // bag nobody can find is not an error in the bag. + assertIs(BagSweep.quote(bag())) + // Two hint tags is one of §7.7's malformed cases. + assertIs(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4)))) + // And a box smaller than the region it claims to hold is another. + assertIs(BagSweep.quote(bag(hint(box444, 3)))) + } + + @Test + fun aBagWhoseVersionIsUnknownIsNotSwept() { + // §8.6: "A reader MUST ignore a bag whose version it does not know." + val future = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"), + arrayOf("version", "3"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + hint(box666, 6), + ), + content = "", + sig = "0".repeat(128), + ) + assertIs(BagSweep.quote(future)) + } + + @Test + fun sweepingTheBoxFindsTheRegionAndOpensTheBag() = + runTest { + val states = BagSweep.sweep(bag(hint(box666, 6))).toList() + + assertIs(states.first(), "priced on this device before the rest is spent") + val opened = assertIs(states.last()) + val contents = assertIs(opened.contents) + assertEquals("just some words, not a list", contents.bytes.decodeToString()) + } + + @Test + fun aDestinationHintOpensOnTheOneCandidateItNames() = + runTest { + // The measurement is never wasted: the box's own base region is the + // first candidate either way, so a gap-0 hint is answered by it. + val states = BagSweep.sweep(bag(hint(box444, 4))).toList() + assertEquals(2, states.size, "measure, then open") + assertIs(states.last()) + } + + @Test + fun aBoxTheHintWasWrongAboutIsSweptToTheEndAndSaysSo() = + runTest { + // The same box, against a bag addressed to a region that is not in + // it. Every candidate is derived and none matches, which is the + // honest outcome of a hint that was wrong or was bait. + val elsewhere = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "f".repeat(64)), + arrayOf("version", "2"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + hint(box666, 6), + ), + content = "", + sig = "0".repeat(128), + ) + + val states = BagSweep.sweep(elsewhere).toList() + val notFound = assertIs(states.last()) + assertEquals(64, notFound.examined, "the whole box, and not one region more") + } + + @Test + fun aQuoteThatWasDeclinedEmitsNothingAtAll() = + runTest { + // Nothing is built for a box the card already refused, which is the + // point of pricing from the tags: the refusal costs no arithmetic. + assertTrue(BagSweep.sweep(bag(hint(box121212, 12))).toList().isEmpty()) + assertTrue(BagSweep.sweep(bag()).toList().isEmpty()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt new file mode 100644 index 0000000000..431098e5fd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt @@ -0,0 +1,106 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.math.abs +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The placeholder an avatar with no shape is drawn as. + * + * Asserted as geometry rather than as a picture, because a hand-written face + * list is exactly the kind of thing that comes out looking almost right: an + * icosahedron is twelve corners, thirty edges and twenty faces, every corner on + * five of them and every edge on two, and a transposed index breaks one of + * those without breaking the others. + */ +class SnoDefaultAvatarTest { + private val payload = SnoDefaultAvatar.payload + + @Test + fun itIsAnIcosahedron() { + assertEquals(12, payload.vertexCount) + assertEquals(20, payload.faceCount) + assertEquals(SnoMode.LINES, payload.mode, "a placeholder should read as a wireframe, not as something somebody made") + } + + @Test + fun everyEdgeJoinsTwoFacesAndEveryCornerFive() { + val onEdge = HashMap() + val onCorner = IntArray(payload.vertexCount) + for (face in 0 until payload.faceCount) { + val corners = intArrayOf(payload.faces[face * 3], payload.faces[face * 3 + 1], payload.faces[face * 3 + 2]) + for (corner in corners) onCorner[corner]++ + for (i in 0..2) { + val a = corners[i] + val b = corners[(i + 1) % 3] + val key = (minOf(a, b).toLong() shl 32) or maxOf(a, b).toLong() + onEdge[key] = (onEdge[key] ?: 0) + 1 + } + } + assertEquals(30, onEdge.size, "an icosahedron has thirty edges") + assertTrue(onEdge.values.all { it == 2 }, "every edge should join exactly two faces") + assertTrue(onCorner.all { it == 5 }, "every corner should be on five faces") + } + + @Test + fun everyEdgeIsTheSameLength() { + // What actually makes it regular. The corners are on the lattice and + // the golden ratio is not, so the edges differ by the rounding and by + // nothing else: 194 ticks against 1.618034 units is 0.02% out. + var shortest = Double.MAX_VALUE + var longest = 0.0 + for (face in 0 until payload.faceCount) { + for (i in 0..2) { + val a = payload.faces[face * 3 + i] + val b = payload.faces[face * 3 + (i + 1) % 3] + var square = 0.0 + for (axis in 0..2) { + val d = (payload.tickAt(a, axis) - payload.tickAt(b, axis)).toDouble() + square += d * d + } + val length = sqrt(square) + if (length < shortest) shortest = length + if (length > longest) longest = length + } + } + assertTrue(longest / shortest < 1.001, "edges ran $shortest to $longest ticks") + // And the edge of an icosahedron of short radius 1 is 2 units. + assertTrue(abs(shortest - 2.0 * SnoPayload.TICKS_PER_UNIT) < 1.0, "an edge should be two units, was $shortest ticks") + } + + @Test + fun itDrawsSomethingAndFitsTheFrame() { + val size = 64 + val pixels = SnoRasterizer.render(payload, size, size) + assertTrue(pixels.count { it != 0 } > 100, "a wireframe icosahedron should light a good many pixels") + // Nothing may spill past the margin the projection leaves. + for (x in 0 until size) { + assertEquals(0, pixels[x], "the top row should be clear") + assertEquals(0, pixels[(size - 1) * size + x], "the bottom row should be clear") + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt new file mode 100644 index 0000000000..52138e5128 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt @@ -0,0 +1,259 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The inspection drawing DECK-0003 §4 permits: "A client MAY light an object + * instead, and many will." + * + * Two things have to hold for that to be worth doing. Every face must wind + * outward first, or a stamped block comes out half black; and the light must be + * per face and flat, from the triangle's own corners, which §4 requires in as + * many words. + */ +class SnoLightingTest { + private val red = 0xFFFF0000.toInt() + + /** Three numbers per row, written out rather than nested, so the shape reads. */ + private fun numbers(text: String) = + text + .trim() + .split(WHITESPACE) + .map { it.toInt() } + .toIntArray() + + /** + * A box on the lattice, as twelve triangles wound outward — except for the + * ones named in [reversed], which are handed back inward, the way half of a + * stamped block's triangles arrive. + */ + private fun box( + bottom: Int = -1, + top: Int = 1, + reversed: Set = emptySet(), + ): SnoPayload { + val t = SnoPayload.TICKS_PER_UNIT + // The eight corners of the unit cube, as 0 or 1 on each axis. + val cube = numbers("0 0 0 1 0 0 1 1 0 0 1 0 0 0 1 1 0 1 1 1 1 0 1 1") + val positions = + IntArray(cube.size) { + when (it % 3) { + 1 -> if (cube[it] == 0) bottom else top + else -> if (cube[it] == 0) -1 else 1 + } * t + } + val faces = + numbers( + """ + 4 5 6 4 6 7 + 0 3 2 0 2 1 + 1 2 6 1 6 5 + 0 4 7 0 7 3 + 3 7 6 3 6 2 + 0 1 5 0 5 4 + """, + ) + for (face in reversed) { + val swap = faces[face * 3 + 1] + faces[face * 3 + 1] = faces[face * 3 + 2] + faces[face * 3 + 2] = swap + } + return payload("box", positions, faces, extent = 8) + } + + private fun payload( + name: String, + positions: IntArray, + faces: IntArray, + extent: Int, + ) = SnoPayload( + version = 2, + name = name, + unit = 0, + extent = extent, + mode = SnoMode.SOLID, + positions = positions, + colors = IntArray(positions.size / 3) { red }, + faces = faces, + faceColors = null, + paletteRef = SnoPaletteRef.BuiltIn, + up = false, + spin = 0, + ) + + /** The face's normal, taken from its own three corners as §4 requires. */ + private fun normalOf( + payload: SnoPayload, + faces: IntArray, + face: Int, + ): FloatArray { + val a = faces[face * 3] * 3 + val b = faces[face * 3 + 1] * 3 + val c = faces[face * 3 + 2] * 3 + val abx = (payload.positions[b] - payload.positions[a]).toFloat() + val aby = (payload.positions[b + 1] - payload.positions[a + 1]).toFloat() + val abz = (payload.positions[b + 2] - payload.positions[a + 2]).toFloat() + val acx = (payload.positions[c] - payload.positions[a]).toFloat() + val acy = (payload.positions[c + 1] - payload.positions[a + 1]).toFloat() + val acz = (payload.positions[c + 2] - payload.positions[a + 2]).toFloat() + return floatArrayOf(aby * acz - abz * acy, abz * acx - abx * acz, abx * acy - aby * acx) + } + + /** Whether this face turns its back on the middle of the object. */ + private fun pointsOutward( + payload: SnoPayload, + faces: IntArray, + face: Int, + ): Boolean { + val normal = normalOf(payload, faces, face) + var away = 0f + for (axis in 0..2) { + var middle = 0f + for (corner in 0..2) middle += payload.positions[faces[face * 3 + corner] * 3 + axis].toFloat() + away += normal[axis] * (middle / 3f) + } + // These objects sit on the origin, so a face's middle is the direction + // out of the object at that face. + return away > 0f + } + + @Test + fun aBoxWhoseTrianglesDisagreeIsWoundOutward() { + // Half of a stamped block's triangles wind inward, which never showed + // while nothing was lit. Every one of them comes back outward. + val payload = box(reversed = setOf(0, 3, 4, 7, 9, 11)) + val winding = SnoFaceWinding.of(payload) + for (face in 0 until payload.faceCount) { + assertTrue(pointsOutward(payload, winding.faces, face), "face $face should face out of the box") + } + } + + @Test + fun aBoxAlreadyWoundOutwardIsLeftAlone() { + val payload = box() + val winding = SnoFaceWinding.of(payload) + assertTrue(winding.faces.contentEquals(payload.faces), "nothing needed turning") + assertTrue(winding.interior.none { it }, "a lone box has no face buried in a join") + // And the payload's own array is untouched, whatever the pass decides. + assertEquals(4, payload.faces[0]) + } + + @Test + fun aLitBoxOfOneColourIsNoLongerAFlatHexagon() { + // The whole reason the switch exists: unlit, a box of a single colour is + // one flat shape however far you turn it, because every pixel of it is + // that colour. + val payload = box() + val flat = SnoRasterizer.render(payload, 64, 64) + val lit = SnoRasterizer.render(payload, 64, 64, lighting = SnoLighting.of(payload)) + + val flatShades = flat.filter { it ushr 24 == 0xFF }.toSet() + val litShades = lit.filter { it ushr 24 == 0xFF }.toSet() + assertEquals(1, flatShades.size, "unlit, every pixel of a one-colour box is the same colour") + assertTrue(litShades.size >= 3, "lit, each of the three visible faces takes its own shade: $litShades") + } + + @Test + fun theLightNeverClipsAndNeverGoesOut() { + // The three lights are scaled so that a face square-on to the key shows + // its colour exactly: nothing washes out to white, and the darkest face + // keeps enough of its colour to read. + val payload = box(reversed = setOf(1, 2, 5, 8)) + val lighting = SnoLighting.of(payload) + for (face in 0 until payload.faceCount) { + assertTrue(lighting.outside[face] > 0.3f, "face $face outside went dark: ${lighting.outside[face]}") + assertTrue(lighting.outside[face] <= 1f, "face $face outside clipped: ${lighting.outside[face]}") + assertTrue( + lighting.inside[face] < lighting.outside[face], + "the inside of face $face should be the darker side: ${lighting.inside[face]} against ${lighting.outside[face]}", + ) + } + } + + @Test + fun theSquareInsideAJoinIsNotDrawn() { + // A block stacked on a block leaves a square between them with faces on + // both sides of it. Lit, it would only fight the face it sits against. + val stacked = merge(box(bottom = -2, top = 0), box(bottom = 0, top = 2)) + val winding = SnoFaceWinding.of(stacked) + + val buried = (0 until stacked.faceCount).filter { winding.interior[it] } + assertEquals(4, buried.size, "the join is two triangles from each block: $buried") + for (face in buried) { + for (corner in 0..2) { + assertEquals(0, stacked.positions[winding.faces[face * 3 + corner] * 3 + 1], "the join lies at y = 0") + } + } + } + + @Test + fun aFlatPlateFarFromTheOriginStillFacesUp() { + // A patch whose rays cross nothing has no vote, and falls back to its + // signed volume and then, when that is zero too, to facing up. The + // volume is the one the divergence theorem gives, and that is only + // independent of where it is measured from for a *closed* surface: for + // an open plate measured from the origin it is the volume of the cone + // between the two, which is large and carries the sign of whichever + // side of the origin the plate lies on. A plate would then be lit on + // its upper face above the origin and on its lower face below it — the + // same object drawn two ways, decided by where §1.8 let the author put + // it. Measured about the patch's own middle that cone is flat, the + // volume is zero, and the plate falls through to facing up wherever it + // lies. + val t = SnoPayload.TICKS_PER_UNIT + val plate = + payload( + "plate", + // A two-unit square lying flat, in the far corner of the lattice + // and below the origin, wound so that it starts out facing down. + IntArray(12) { numbers("60 -62 60 62 -62 60 62 -62 62 60 -62 62")[it] * t }, + numbers("0 1 2 0 2 3"), + extent = 64, + ) + val winding = SnoFaceWinding.of(plate) + for (face in 0 until plate.faceCount) { + assertTrue(normalOf(plate, winding.faces, face)[1] > 0f, "face $face of a plate should end up facing up") + } + assertTrue(winding.interior.none { it }, "a plate has nothing buried in it") + } + + /** Two objects in one payload, as a bag of stamps would be. */ + private fun merge( + first: SnoPayload, + second: SnoPayload, + ) = payload( + "stack", + first.positions + second.positions, + first.faces + IntArray(second.faces.size) { second.faces[it] + first.vertexCount }, + extent = 8, + ) + + companion object { + private val WHITESPACE = Regex("\\s+") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt new file mode 100644 index 0000000000..3dc6d0a791 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt @@ -0,0 +1,335 @@ +/* + * 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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The rendering decisions DECK-0003 §4 makes, asserted on pixels. + * + * §4 decides shading rather than leaving it to taste precisely so that the same + * object does not look like two objects, so each of its rules gets a test here + * rather than a comment. + */ +class SnoRasterizerTest { + private val dim = 64 + + private fun parse(json: String): SnoPayload { + val payload = SnoParser.parse(json).payloadOrNull() + assertNotNull(payload, "fixture did not parse: $json") + return payload + } + + /** A single triangle filling most of the frame, facing the viewer. */ + private fun triangle( + colors: String = "[238,238,238]", + extra: String = "", + mode: String = "solid", + ) = parse( + """{"v":2,"name":"t","unit":0,"mode":"$mode","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":$colors,"faces":[[0,1,2]]$extra}""", + ) + + private fun IntArray.at( + x: Int, + y: Int, + ) = this[y * dim + x] + + private fun IntArray.litCount() = count { it != 0 } + + @Test + fun aSolidTriangleFillsItsMiddleAndLeavesTheCornersAlone() { + val pixels = SnoRasterizer.render(triangle(), dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + assertEquals(0xFFFF0000.toInt(), pixels.at(dim / 2, dim / 2), "the centre should be the face") + assertEquals(0, pixels.at(0, 0), "a corner outside the triangle stays transparent") + assertEquals(0, pixels.at(dim - 1, 0)) + } + + @Test + fun theBackgroundIsHonoured() { + val pixels = SnoRasterizer.render(triangle(), dim, dim, yawDegrees = 0f, pitchDegrees = 0f, background = 0xFF101010.toInt()) + assertEquals(0xFF101010.toInt(), pixels.at(0, 0)) + assertEquals(0xFFFF0000.toInt(), pixels.at(dim / 2, dim / 2)) + } + + @Test + fun vertexColoursInterpolateAcrossAFace() { + // §4: the default reading is unlit, and a face takes its colour by + // interpolating its three vertices. + val pixels = SnoRasterizer.render(triangle(colors = "[238,235,239]"), dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + val middle = pixels.at(dim / 2, dim / 2) + val r = middle shr 16 and 0xFF + val g = middle shr 8 and 0xFF + val b = middle and 0xFF + assertTrue(r in 1..254 && g in 1..254 && b in 1..254, "the centre of a red/green/blue triangle should be a mix, was $r,$g,$b") + } + + @Test + fun aFaceColourFillsFlatlyAndItsVerticesContributeNothing() { + // §1.4a: when a face has a colour, that colour fills the whole triangle. + // The seam between two such faces is exact, which is the point of it. + val pixels = + SnoRasterizer.render( + triangle(colors = "[238,235,239]", extra = ""","facecolors":[225]"""), + dim, + dim, + yawDegrees = 0f, + pitchDegrees = 0f, + ) + val lit = pixels.filter { it != 0 } + assertTrue(lit.isNotEmpty()) + assertEquals(0xFFFFFFFF.toInt(), pixels.at(dim / 2, dim / 2), "the fill should be the face colour, opaque") + // Colour only: the vertex dots §1.5 lays over every mode feather at + // their rim, so a pixel at the edge of one carries partial alpha. What + // §1.4a decides is the colour, and no pixel anywhere shows a trace of + // the red, green and blue this object's vertices carry. + assertTrue(lit.all { it and 0xFFFFFF == 0xFFFFFF }, "every covered pixel should be the face colour, flat") + } + + @Test + fun bothSidesOfAFaceAreDrawn() { + // §1.4: a face has no front and no back, and a reader MUST NOT cull on + // the basis of winding. + val clockwise = parse("""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]]}""") + val counter = parse("""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,2,1]]}""") + + val a = SnoRasterizer.render(clockwise, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + val b = SnoRasterizer.render(counter, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + assertTrue(a.contentEquals(b), "reversing a face's winding must not change what is drawn") + assertTrue(a.litCount() > 100) + } + + @Test + fun theNearerFaceWinsWhicheverOrderItIsListedIn() { + // This is what the depth buffer buys over a painter's-algorithm + // fallback: two coplanar-in-screen triangles at different depths sort + // correctly no matter how the payload orders them. + val nearLast = + parse( + """{"v":2,"name":"z","unit":0,"mode":"solid", + "vertices":[[-4,-4,-2],[4,-4,-2],[0,4,-2],[-4,-4,2],[4,-4,2],[0,4,2]], + "colors":[238,238,238,235,235,235],"faces":[[0,1,2],[3,4,5]]}""", + ) + val nearFirst = + parse( + """{"v":2,"name":"z","unit":0,"mode":"solid", + "vertices":[[-4,-4,2],[4,-4,2],[0,4,2],[-4,-4,-2],[4,-4,-2],[0,4,-2]], + "colors":[235,235,235,238,238,238],"faces":[[0,1,2],[3,4,5]]}""", + ) + + val a = SnoRasterizer.render(nearLast, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + val b = SnoRasterizer.render(nearFirst, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + assertEquals(0xFF00FF00.toInt(), a.at(dim / 2, dim / 2), "the nearer (green) face should win") + assertEquals(0xFF00FF00.toInt(), b.at(dim / 2, dim / 2), "...in either listing order") + } + + @Test + fun pointsModeDrawsTheVerticesAndNotTheTriangle() { + val pixels = SnoRasterizer.render(triangle(mode = "points"), dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + // Three round dots a couple of pixels across, and nothing between them: + // the whole triangle would be some 1400 pixels. + assertTrue(pixels.litCount() in 3..60, "three vertices should light three small dots, lit ${pixels.litCount()}") + assertEquals(0, pixels.at(dim / 2, dim / 2), "points mode must not fill the triangle") + } + + @Test + fun theVerticesAreDrawnInEveryMode() { + // §1.5: "A client SHOULD also draw the vertices as points in every + // mode". Under a solid they are a hint rather than a second shape — + // small, and laid over the fill at part alpha — so what shows they are + // there is that the dot on the apex reaches a little past it, into + // pixels no fill of this triangle can cover. + val wide = 512 + val pixels = SnoRasterizer.render(triangle(), wide, wide, yawDegrees = 0f, pitchDegrees = 0f) + // The apex of the triangle lands just under a thirteenth of the way down. + val apexRow = (wide * 0.08f).toInt() + assertEquals(0, pixels[(apexRow - 10) * wide + wide / 2], "well above the apex is empty") + assertTrue(pixels[apexRow * wide + wide / 2] != 0, "the apex's own vertex dot reaches above the fill") + } + + @Test + fun aFaceColourMarksItsVerticesInThatColourToo() { + // The other half of §1.4a: where a face carries its own colour, that is + // what the object shows, and the vertex dots over it are that colour as + // well rather than the three the payload's `colors` still carries. + val pixels = + SnoRasterizer.render( + triangle(colors = "[238,235,239]", extra = ""","facecolors":[225]"""), + dim, + dim, + yawDegrees = 0f, + pitchDegrees = 0f, + ) + assertTrue(pixels.filter { it != 0 }.all { it and 0xFFFFFF == 0xFFFFFF }, "no dot should show a vertex colour") + } + + @Test + fun linesModeDrawsEachSharedEdgeOnce() { + // A closed pair of triangles sharing an edge: the outline is drawn, the + // interior is not, and the shared edge costs one line rather than two. + val quad = + parse( + """{"v":2,"name":"q","unit":0,"mode":"lines", + "vertices":[[-4,-4,0],[4,-4,0],[4,4,0],[-4,4,0]], + "colors":[225,225,225,225],"faces":[[0,1,2],[0,2,3]]}""", + ) + val lines = SnoRasterizer.render(quad, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + val solid = SnoRasterizer.render(parse(solidQuadJson), dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + + assertTrue(lines.litCount() > 0) + assertTrue(lines.litCount() * 4 < solid.litCount(), "lines mode must not fill: lit ${lines.litCount()} against a solid ${solid.litCount()}") + + // A point inside the lower triangle and off every edge stays empty... + assertEquals(0, lines.at(dim / 2 + 13, dim / 2 + 13), "lines mode must not fill") + + // ...while the edge the two triangles share is drawn. In the wire format + // a face IS a triangle, so that diagonal is a real edge of both faces and + // belongs in the outline; it is drawn once rather than twice, which is + // what the shared-edge rule of §1.5 buys. + assertTrue(lines.at(dim / 2, dim / 2) != 0, "the shared edge should be drawn") + } + + private val solidQuadJson = + """{"v":2,"name":"q","unit":0,"mode":"solid", + "vertices":[[-4,-4,0],[4,-4,0],[4,4,0],[-4,4,0]], + "colors":[225,225,225,225],"faces":[[0,1,2],[0,2,3]]}""" + + @Test + fun linesWithNoFacesIsOnePolylineThroughTheVerticesInOrder() { + val path = + parse( + """{"v":2,"name":"p","unit":0,"mode":"lines", + "vertices":[[-4,-4,0],[0,4,0],[4,-4,0]],"colors":[225,225,225],"faces":[]}""", + ) + val pixels = SnoRasterizer.render(path, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + assertTrue(pixels.litCount() > 20, "a polyline of two segments should light a run of pixels") + } + + @Test + fun anObjectTooSmallForATriangleIsStillVisible() { + // §1.5: a client SHOULD also draw the vertices as points in every mode, + // so that an object remains visible when it is smaller on screen than a + // triangle. + val pixels = SnoRasterizer.render(triangle(), 2, 2, yawDegrees = 0f, pitchDegrees = 0f) + assertTrue(pixels.any { it != 0 }, "a two-pixel raster should still show something") + } + + @Test + fun nothingIsDrawnOutsideTheBuffer() { + // The limits are the defence, and the buffer is sized from the raster + // rather than from anything the payload says. + val pixels = SnoRasterizer.render(triangle(), 17, 5, yawDegrees = 37f, pitchDegrees = -61f) + assertEquals(17 * 5, pixels.size) + } + + @Test + fun aSingleVertexDoesNotCrash() { + val dot = parse("""{"v":2,"name":"d","unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[225],"faces":[]}""") + val pixels = SnoRasterizer.render(dot, dim, dim) + // One vertex is the whole object, so it is drawn at the size the shape + // profile allows and centred in the frame. + assertTrue(pixels.litCount() in 1..160, "one vertex, lit ${pixels.litCount()}") + assertTrue(pixels.at(dim / 2, dim / 2) != 0, "the one vertex should be in the middle") + } + + @Test + fun coincidentVerticesDoNotDivideByZero() { + val degenerate = + parse( + """{"v":2,"name":"d","unit":0,"mode":"solid","vertices":[[1,1,1],[1,1,1],[1,1,1]],"colors":[225,225,225],"faces":[[0,1,2]]}""", + ) + val pixels = SnoRasterizer.render(degenerate, dim, dim) + assertEquals(dim * dim, pixels.size) + } + + @Test + fun theSameObjectRendersIdenticallyEveryTime() { + val payload = triangle(colors = "[238,235,239]") + val a = SnoRasterizer.render(payload, dim, dim) + val b = SnoRasterizer.render(payload, dim, dim) + assertTrue(a.contentEquals(b)) + } + + @Test + fun rotatingChangesTheDrawingButNotTheModel() { + val payload = triangle() + val front = SnoRasterizer.render(payload, dim, dim, yawDegrees = 0f, pitchDegrees = 0f) + val turned = SnoRasterizer.render(payload, dim, dim, yawDegrees = 60f, pitchDegrees = 0f) + assertTrue(!front.contentEquals(turned), "a yaw should change the raster") + + // No invented geometry, and nothing moved: the positions are untouched. + assertEquals(-480, payload.tickAt(0, 0)) + assertEquals(480, payload.tickAt(1, 0)) + } + + /** + * Reused buffers must draw the same picture as fresh ones. + * + * The whole risk of [SnoRasterScratch] is that a frame inherits something + * the last one left behind — a pixel the background fill missed, a depth + * still holding the previous angle's surface in front of this one. So this + * turns the object through a sequence of angles twice, once allocating and + * once reusing, and every frame has to match: a leak would show up on the + * second angle onward, never on the first. + */ + @Test + fun aReusedBufferDrawsWhatAFreshOneDraws() { + val payload = triangle(colors = "[238,235,239]") + val scratch = SnoRasterScratch() + val background = 0xFF101010.toInt() + + for (yaw in floatArrayOf(0f, 37f, 180f, -95f, 0f)) { + val fresh = SnoRasterizer.render(payload, dim, dim, yawDegrees = yaw, background = background) + val reused = + SnoRasterizer.render(payload, dim, dim, yawDegrees = yaw, background = background, scratch = scratch) + assertTrue(fresh.contentEquals(reused), "a reused buffer differs from a fresh one at yaw $yaw") + } + } + + /** A drag ends and the raster goes back to full size; the buffers must follow. */ + @Test + fun aReusedBufferFollowsAChangeOfSize() { + val payload = triangle() + val scratch = SnoRasterScratch() + + for (size in intArrayOf(dim, dim * 2, 3, dim)) { + val reused = SnoRasterizer.render(payload, size, size, scratch = scratch) + assertEquals(size * size, reused.size, "the buffer did not resize to $size") + assertTrue(reused.contentEquals(SnoRasterizer.render(payload, size, size))) + } + } + + /** A lit frame must not leave its shading in the buffer for an unlit one. */ + @Test + fun aReusedBufferDoesNotCarryLightingOver() { + val payload = parse(solidQuadJson) + val scratch = SnoRasterScratch() + + SnoRasterizer.render(payload, dim, dim, lighting = SnoLighting.of(payload), scratch = scratch) + val unlitAfterLit = SnoRasterizer.render(payload, dim, dim, scratch = scratch).copyOf() + + assertTrue(unlitAfterLit.contentEquals(SnoRasterizer.render(payload, dim, dim))) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt new file mode 100644 index 0000000000..22dda059f1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt @@ -0,0 +1,135 @@ +/* + * 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.commons.prodbench + +import com.vitorpamplona.amethyst.commons.sno.SnoLighting +import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * What it costs to draw an object, and a floor under it. + * + * The rasterizer is the one hot path this feature owns: a feed thumbnail pays + * it once per object and the viewer pays it per frame of a drag. Its inner loop + * evaluates the edge functions incrementally, which is worth about 4x over + * recomputing them per pixel, and this is here so that a change which quietly + * gives that back shows up as a number rather than as a slow phone. + * + * Two shapes, because they cost very differently: + * - **the largest object actually on the network**, 118 vertices and 94 faces; + * - **an adversarial one at the format's ceiling**, 512 vertices and 1004 faces + * scattered at random so that every triangle spans much of the frame. Nothing + * an author would build, but §1.8's limits permit it and a stranger can + * publish it, so it is the number the viewer's off-thread raster and its + * 512px cap exist to survive. + * + * The assertion is deliberately loose — a hundredfold over the measured cost — + * so it catches an order-of-magnitude regression without flaking on a busy CI box. + */ +class SnoRasterBenchmark { + private val real = + SnoParser + .parse("{\"v\":2,\"name\":\"first object\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-1,0,1],[3,0,1],[3,0,-3],[-1,0,-3],[1,4,-1],[-5,0,1],[-1,0,1],[-1,0,-3],[-5,0,-3],[-3,4,-1],[-5,0,5],[-1,0,5],[-1,0,1],[-5,0,1],[-3,4,3],[-1,0,2],[3,0,2],[3,0,-2],[-1,0,-2],[1,4,0],[-3,0,2],[1,0,2],[1,0,-2],[-3,0,-2],[-1,4,0],[-4,0,3],[0,0,3],[0,0,-1],[-4,0,-1],[-2,4,1],[-4,0,-2],[0,0,-2],[0,0,-6],[-4,0,-6],[-2,4,-4],[-8,0,1],[-6,0,1],[-6,0,-1],[-8,0,-1],[-8,2,1],[-8,2,-1],[-8,0,-2],[-6,0,-2],[-6,0,-4],[-8,0,-4],[-8,2,-2],[-8,2,-4],[-8,0,-4],[-6,0,-4],[-6,0,-6],[-8,0,-6],[-8,2,-4],[-8,2,-6],[-8,0,2],[-6,0,2],[-6,0,0],[-8,0,0],[-8,2,2],[-8,2,0],[2,0,7],[4,0,7],[4,0,5],[2,0,5],[2,2,7],[2,2,5],[-2,0,7],[0,0,7],[0,0,5],[-2,0,5],[-2,2,7],[-2,2,5]],\"ticks\":[-118],\"colors\":[226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226],\"faces\":[[43,42,46],[44,43,46],[45,44,46],[42,45,46],[42,43,44],[42,44,45],[48,47,51],[49,48,51],[50,49,51],[47,50,51],[47,48,49],[47,49,50],[53,52,56],[54,53,56],[55,54,56],[52,55,56],[52,53,54],[52,54,55],[58,57,61],[59,58,61],[60,59,61],[57,60,61],[57,58,59],[57,59,60],[63,62,66],[64,63,66],[65,64,66],[62,65,66],[62,63,64],[62,64,65],[68,67,71],[69,68,71],[70,69,71],[67,70,71],[67,68,69],[67,69,70],[73,72,76],[74,73,76],[75,74,76],[72,75,76],[72,73,74],[72,74,75],[78,77,81],[79,78,81],[80,79,81],[77,80,81],[77,78,79],[77,79,80],[82,83,84],[82,84,85],[82,85,87],[82,87,86],[83,86,87],[83,87,84],[83,82,86],[85,84,87],[88,89,90],[88,90,91],[88,91,93],[88,93,92],[89,92,93],[89,93,90],[89,88,92],[94,95,96],[94,96,97],[94,97,99],[94,99,98],[95,98,99],[95,99,96],[97,96,99],[100,101,102],[100,102,103],[100,103,105],[100,105,104],[101,104,105],[101,105,102],[101,100,104],[103,102,105],[106,107,108],[106,108,109],[106,109,111],[106,111,110],[107,110,111],[107,111,108],[107,106,110],[109,108,111],[112,113,114],[112,114,115],[112,115,117],[112,117,116],[113,116,117],[113,117,114],[113,112,116],[115,114,117]]}") + .payloadOrNull()!! + + private val adversarial: SnoPayload by lazy { + val rnd = Random(7) + val vertices = (0 until 512).joinToString(",") { "[${rnd.nextInt(-8, 9)},${rnd.nextInt(-8, 9)},${rnd.nextInt(-8, 9)}]" } + val colors = (0 until 512).joinToString(",") { (it % 256).toString() } + val faces = (0 until 1004).joinToString(",") { "[${it % 512},${(it + 1) % 512},${(it + 2) % 512}]" } + SnoParser + .parse("""{"v":2,"name":"bench","unit":0,"mode":"solid","vertices":[$vertices],"colors":[$colors],"faces":[$faces]}""") + .payloadOrNull()!! + } + + private fun millisPerFrame( + payload: SnoPayload, + size: Int, + lighting: SnoLighting? = null, + ): Double { + repeat(WARMUP) { SnoRasterizer.render(payload, size, size, lighting = lighting) } + val start = System.nanoTime() + repeat(RUNS) { SnoRasterizer.render(payload, size, size, lighting = lighting) } + return (System.nanoTime() - start) / 1e6 / RUNS + } + + private fun millisToLight(payload: SnoPayload): Double { + repeat(3) { SnoLighting.of(payload) } + val start = System.nanoTime() + repeat(5) { SnoLighting.of(payload) } + return (System.nanoTime() - start) / 1e6 / 5 + } + + @Test + fun aRealObjectIsCheapToDraw() { + val thumbnail = millisPerFrame(real, THUMBNAIL_PX) + val viewer = millisPerFrame(real, VIEWER_PX) + val full = millisPerFrame(real, 1080) + val adversarialFull = millisPerFrame(adversarial, 1080) + println("real (118v/94f): ${THUMBNAIL_PX}px ${fmt(thumbnail)} ms, ${VIEWER_PX}px ${fmt(viewer)} ms, 1080px ${fmt(full)} ms") + println("adversarial at 1080px: ${fmt(adversarialFull)} ms") + + assertTrue(thumbnail < 5.0, "a feed thumbnail of a real object should be far under a frame, was ${fmt(thumbnail)} ms") + assertTrue(viewer < 50.0, "a viewer frame of a real object should be well under a drag's budget, was ${fmt(viewer)} ms") + } + + @Test + fun theAdversarialCeilingStaysBounded() { + val viewer = millisPerFrame(adversarial, VIEWER_PX) + println("adversarial (512v/1004f): ${VIEWER_PX}px ${fmt(viewer)} ms") + + // Sluggish by design rather than frozen: this one is why the viewer + // rasters off the composition thread and caps its size. + assertTrue(viewer < 500.0, "the ceiling should stay inside the cap's budget, was ${fmt(viewer)} ms") + } + + @Test + fun theLightIsPaidOncePerObjectAndNotPerFrame() { + // Winding the faces outward walks a ray from every face against every + // other one, which is quadratic in the face count and by far the most + // expensive thing this feature does. It depends on the object alone, so + // the viewer holds it across a turn; what has to stay cheap is the + // frame, and a lit frame differs from a flat one by three multiplies a + // pixel. + val realCost = millisToLight(real) + val ceiling = millisToLight(adversarial) + val flatFrame = millisPerFrame(real, VIEWER_PX) + val litFrame = millisPerFrame(real, VIEWER_PX, SnoLighting.of(real)) + println("winding + light: real (94f) ${fmt(realCost)} ms, adversarial (1004f) ${fmt(ceiling)} ms") + println("a ${VIEWER_PX}px frame of a real object: flat ${fmt(flatFrame)} ms, lit ${fmt(litFrame)} ms") + + assertTrue(realCost < 50.0, "lighting a real object should be a blink, was ${fmt(realCost)} ms") + assertTrue(litFrame < flatFrame * 2 + 5, "a lit frame should cost about what a flat one does, was ${fmt(litFrame)} ms") + } + + private fun fmt(value: Double) = ((value * 100).toLong() / 100.0).toString() + + companion object { + private const val WARMUP = 10 + private const val RUNS = 20 + private const val THUMBNAIL_PX = 96 + private const val VIEWER_PX = 512 + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt index 672cfdea8b..42fc0ad49c 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.image +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import coil3.Image import coil3.asImage import com.vitorpamplona.amethyst.commons.base64Image.toBitmap @@ -30,3 +32,5 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Image actual fun PlatformImage.toCoilImage(): Image = toAndroidBitmap().asImage(true) actual fun base64DataUriToCoilImage(dataUri: String): Image = Base64Image.toBitmap(dataUri).asImage(true) + +actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toAndroidBitmap().asImageBitmap() diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index ad7b890738..b0fc2cb0e0 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5320,4 +5320,35 @@ %1$d new message %1$d new messages + 3D object + %1$d vertices · %2$d faces + This 3D object could not be read (rule %1$s) + Cyberspace avatar + This avatar has not paid for its size, so it is not drawn + Lit; tap to draw the object flat + Flat; tap to light the object + 1 unit = %1$s + Hidden in dataspace · %1$s + Hidden in ideaspace · %1$s + Default avatar + No shape published, so everyone sees this one + Hidden at a place + Hidden in a box of %1$s regions + Hidden in one region, which the hint names exactly + No hint, so this could be anywhere in cyberspace + Out of reach: the hint names 2^%1$d regions to search + Search for it + Stop + Pricing the search on this device… + Searched %1$s of %2$s regions + Out of reach: about %1$d minutes of searching on this device + Out of reach: about %1$d hours of searching on this device + Not in the box the hint named + Found the region, but the contents could not be read + %1$d item hidden here did not match its signature and was dropped + %1$d items hidden here did not match their signatures and were dropped + Opened, and there was nothing inside + Unsigned, so this author is a claim + An item of kind %1$d, which this client does not draw + %1$d bytes of something this client does not read diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt index 8b6d70440f..45979589d1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.image +import androidx.compose.ui.graphics.ImageBitmap import coil3.Image import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage @@ -28,3 +29,9 @@ expect fun PlatformImage.toCoilImage(): Image /** Decodes a base64 `data:` image URI into a Coil image; throws on malformed input. */ expect fun base64DataUriToCoilImage(dataUri: String): Image + +/** + * Converts a decoded [PlatformImage] straight into a Compose image, for a + * drawing that changes every frame and so has no business in an image cache. + */ +expect fun PlatformImage.toComposeImageBitmap(): ImageBitmap diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt new file mode 100644 index 0000000000..b781b40a36 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt @@ -0,0 +1,115 @@ +/* + * 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.commons.sno + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.service.image.toCoilImage +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload + +/** + * A Simple Nostr Object to draw, addressed by the event that carried it. + * + * [cacheKey] is what Coil's memory cache keys on, so it has to name everything + * that changes the pixels: the event, the view, the size — and the colours. + * + * The geometry needs no part in the key, because an addressable object + * republished under the same `d` arrives as a new event id. The colours are not + * like that. DECK-0003 §1.3a lets `colors` index a palette held in *another* + * event, and until that event is fetched the object is drawn against the + * built-in (§1.3b), so one event id legitimately produces two different + * pictures within a second of each other. Keyed on the id alone, the first + * would be served from memory forever and the fetch would buy nothing. + */ +@Stable +data class SnoObjectToRender( + val payload: SnoPayload, + val eventId: String, + val size: Int, + val yawDegrees: Float = SnoRasterizer.DEFAULT_YAW_DEGREES, + val pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES, + val background: Int = 0, +) { + val cacheKey: String get() = "sno:$eventId:$size:$yawDegrees:$pitchDegrees:$background:${colourSignature()}" + + /** + * A stand-in for the resolved palette: the colours themselves, hashed. + * + * The palette reference cannot serve instead, because what changes is not + * which palette was named but whether it has arrived yet, and the payload + * carries only the name. The colours are already resolved to ARGB by the + * parser, so they are the thing that actually differs. + */ + private fun colourSignature(): Int = 31 * payload.colors.contentHashCode() + (payload.faceColors?.contentHashCode() ?: 0) +} + +/** + * Draws an SNO into Coil's pipeline, so a thumbnail is rasterised once and then + * served from the memory cache like any other image. + * + * The same shape as [com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher]: + * pixels out of a headless decoder in `commons`, into a [PlatformImage], into a + * Coil image. Nothing here is platform-specific. + */ +@Stable +class SnoFetcher( + private val data: SnoObjectToRender, +) : Fetcher { + override suspend fun fetch(): FetchResult { + val pixels = + SnoRasterizer.render( + payload = data.payload, + width = data.size, + height = data.size, + yawDegrees = data.yawDegrees, + pitchDegrees = data.pitchDegrees, + background = data.background, + ) + + return ImageFetchResult( + image = PlatformImage.create(pixels, data.size, data.size).toCoilImage(), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: SnoObjectToRender, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = SnoFetcher(data) + } + + object SKeyer : Keyer { + override fun key( + data: SnoObjectToRender, + options: Options, + ): String = data.cacheKey + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt new file mode 100644 index 0000000000..dbbd5c80f2 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -0,0 +1,304 @@ +/* + * 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.commons.sno.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.sno_light_off +import com.vitorpamplona.amethyst.commons.resources.sno_light_on +import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap +import com.vitorpamplona.amethyst.commons.sno.SnoLighting +import com.vitorpamplona.amethyst.commons.sno.SnoRasterScratch +import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource + +/** How many degrees a one-finger drag of one pixel turns the object. */ +private const val DEGREES_PER_PIXEL = 0.5f + +/** Past straight up or straight down there is nothing more to see. */ +private const val MAX_PITCH = 89f + +private const val MIN_ZOOM = 0.5f +private const val MAX_ZOOM = 8f + +/** + * The raster size while a gesture is in flight. + * + * Turning an object is the thing this view is for, so the frames during a turn + * are the ones that must not stutter. They are also the ones nobody is + * inspecting closely: the sharp frame is the one you stop on. So the raster + * drops while a finger is down and goes back to full size when it lifts. + */ +private const val GESTURE_RASTER_PX = 384 + +/** + * The largest raster produced when the object is at rest, before zoom. + * + * A real object at 1080px costs under 5ms, so full resolution is affordable on + * anything a phone will ask for; this only bounds the memory a very large + * window or a deep zoom could otherwise demand. + */ +private const val MAX_RESTING_RASTER_PX = 1440 + +/** The light switch, small enough to sit over a corner of the object. */ +private val TOGGLE_SIZE = 32.dp +private val TOGGLE_INSET = 4.dp + +/** + * A Simple Nostr Object the reader can turn, move and zoom. + * + * One finger turns it, two move and scale it, and a double tap puts it back. + * + * Only turning needs a new raster — it changes which faces point at you. + * Moving and scaling are a [graphicsLayer] transform of the frame already + * drawn, which costs nothing per frame; when the gesture ends the object is + * redrawn at the resolution the zoom now deserves, so it sharpens where it + * settled rather than staying an upscaled bitmap. + * + * A solid object also offers a light switch, because turning something over to + * understand its shape is the one case DECK-0003 §4 has in mind when it allows + * a client to light an object: unlit, a cube of a single colour is a flat + * hexagon however far you turn it. The feed keeps §4's unlit default, so an + * object looks the same everywhere it is quoted, and the switch starts off. + * See [SnoLighting] for what it does and what it costs. + * + * Unlike [SnoThumbnail] this does not go through Coil: a turn produces a new + * angle every frame and each would become its own cache entry, evicting the + * image cache within a second. Nothing here is worth caching — the object is + * the event, and redrawing it is cheaper than remembering it. + */ +@Composable +fun SnoObjectViewer( + payload: SnoPayload, + eventId: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + background: Color = Color.Transparent, +) { + var yaw by remember(eventId) { mutableFloatStateOf(SnoRasterizer.DEFAULT_YAW_DEGREES) } + var pitch by remember(eventId) { mutableFloatStateOf(SnoRasterizer.DEFAULT_PITCH_DEGREES) } + var zoom by remember(eventId) { mutableFloatStateOf(1f) } + var panX by remember(eventId) { mutableFloatStateOf(0f) } + var panY by remember(eventId) { mutableFloatStateOf(0f) } + var gesturing by remember(eventId) { mutableStateOf(false) } + var lit by remember(eventId) { mutableStateOf(false) } + // Winding the faces outward walks a ray from every face against every other + // one, so it is worth its own cache: it depends on the object alone and not + // on the angle, and a turn would otherwise pay for it sixty times a second. + val lighting = remember(payload) { LightingCache() } + val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb() + + BoxWithConstraints( + modifier = + modifier + .pointerInput(eventId) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + gesturing = true + try { + do { + val event = awaitPointerEvent(PointerEventPass.Main) + val moved = event.changes.any { it.positionChanged() } + if (moved) { + if (event.changes.size > 1) { + // Two fingers move and scale the object. + val scale = event.calculateZoom() + if (scale != 1f) zoom = (zoom * scale).coerceIn(MIN_ZOOM, MAX_ZOOM) + val pan = event.calculatePan() + panX += pan.x + panY += pan.y + } else { + // One finger turns it. + val pan = event.calculatePan() + yaw = wrapDegrees(yaw + pan.x * DEGREES_PER_PIXEL) + pitch = (pitch - pan.y * DEGREES_PER_PIXEL).coerceIn(-MAX_PITCH, MAX_PITCH) + } + event.changes.forEach { it.consume() } + } + } while (event.changes.any { it.pressed }) + } finally { + gesturing = false + } + } + }.pointerInput(eventId) { + detectTapGestures( + onDoubleTap = { + yaw = SnoRasterizer.DEFAULT_YAW_DEGREES + pitch = SnoRasterizer.DEFAULT_PITCH_DEGREES + zoom = 1f + panX = 0f + panY = 0f + }, + ) + }, + contentAlignment = Alignment.Center, + ) { + val onScreenPx = with(LocalDensity.current) { minOf(maxWidth, maxHeight).roundToPx() } + if (onScreenPx <= 0) return@BoxWithConstraints + + // Coarse while a finger is down, full — and scaled by how far in the + // reader has zoomed — once it lifts. + val restingPx = minOf((onScreenPx * zoom).toInt(), MAX_RESTING_RASTER_PX) + val rasterPx = if (gesturing) minOf(onScreenPx, GESTURE_RASTER_PX) else restingPx + + // Held across angles rather than recomputed in composition: the previous + // frame stays on screen while the next is drawn, so a heavy object makes + // the turn coarse instead of freezing the gesture. + var bitmap by remember(eventId) { mutableStateOf(null) } + + // The buffers every frame of the turn draws into. Held here rather than + // allocated per frame: see [SnoRasterScratch] — a drag that allocates + // them spends more of the device on collecting the last frame's pair + // than on drawing the next one. + val scratch = remember(eventId) { SnoRasterScratch() } + + // One collector, and angles conflated into it. A finger produces a new + // angle far faster than this device draws one, and `render` runs to + // completion whether or not anyone still wants it, so an effect + // restarted per angle would leave several frames in flight at once — + // all but one of them discarded, and all of them writing over each + // other's `scratch`. Conflating keeps only the newest angle waiting, so + // exactly one frame is ever being drawn and the turn still lands on + // wherever the finger actually stopped. + LaunchedEffect(eventId, payload, rasterPx, backgroundArgb) { + snapshotFlow { Triple(yaw, pitch, lit) } + .conflate() + .collect { (atYaw, atPitch, isLit) -> + bitmap = + withContext(Dispatchers.Default) { + val pixels = + SnoRasterizer.render( + payload = payload, + width = rasterPx, + height = rasterPx, + yawDegrees = atYaw, + pitchDegrees = atPitch, + background = backgroundArgb, + lighting = if (isLit) lighting.of(payload) else null, + scratch = scratch, + ) + // Copies into the bitmap, so `scratch` is free to be + // drawn over by the next frame. + PlatformImage.create(pixels, rasterPx, rasterPx).toComposeImageBitmap() + } + } + } + + bitmap?.let { drawn -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Image( + bitmap = drawn, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = zoom, + scaleY = zoom, + translationX = panX, + translationY = panY, + ), + ) + } + } + + // Only a filled object has faces for a light to fall on; points and + // wireframes look the same lit or not, so they are not offered a switch. + if (payload.mode == SnoMode.SOLID && payload.faceCount > 0) { + IconButton( + onClick = { lit = !lit }, + modifier = Modifier.align(Alignment.TopEnd).padding(TOGGLE_INSET).size(TOGGLE_SIZE), + ) { + Icon( + MaterialSymbols.BrightnessMedium, + contentDescription = stringResource(if (lit) Res.string.sno_light_on else Res.string.sno_light_off), + tint = + if (lit) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + filled = lit, + ) + } + } + } +} + +/** + * One object's lighting, kept until the object changes. + * + * Not Compose state: it is written from the drawing coroutine and read only + * there, and making it state would recompose the view for a value nothing in + * composition looks at. + */ +private class LightingCache { + private var lighting: SnoLighting? = null + + fun of(payload: SnoPayload): SnoLighting = lighting ?: SnoLighting.of(payload).also { lighting = it } +} + +private fun wrapDegrees(value: Float): Float { + var wrapped = value % 360f + if (wrapped < 0f) wrapped += 360f + return wrapped +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt new file mode 100644 index 0000000000..68ce3aacec --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt @@ -0,0 +1,79 @@ +/* + * 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.commons.sno.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.sno.SnoObjectToRender +import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload + +/** + * A Simple Nostr Object drawn at a fixed size, for a feed or a thread. + * + * Rasterised once per (event, size, view) through Coil, so scrolling past the + * same object costs a memory-cache hit rather than a re-render. + */ +@Composable +fun SnoThumbnail( + payload: SnoPayload, + eventId: String, + size: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, + yawDegrees: Float = SnoRasterizer.DEFAULT_YAW_DEGREES, + pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES, + background: Color = Color.Transparent, +) { + val pixelSize = with(LocalDensity.current) { size.roundToPx() } + val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb() + + val request = + remember(payload, eventId, pixelSize, yawDegrees, pitchDegrees, backgroundArgb) { + SnoObjectToRender( + payload = payload, + eventId = eventId, + size = pixelSize, + yawDegrees = yawDegrees, + pitchDegrees = pitchDegrees, + background = backgroundArgb, + ) + } + + Box(modifier.size(size), contentAlignment = Alignment.Center) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = Modifier.size(size), + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt new file mode 100644 index 0000000000..9437e60b02 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt @@ -0,0 +1,169 @@ +/* + * 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.commons.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default +import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default_details +import com.vitorpamplona.amethyst.commons.resources.sno_avatar_title +import com.vitorpamplona.amethyst.commons.resources.sno_avatar_unpaid +import com.vitorpamplona.amethyst.commons.resources.sno_object_details +import com.vitorpamplona.amethyst.commons.resources.sno_object_scale +import com.vitorpamplona.amethyst.commons.sno.SnoDefaultAvatar +import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.quartz.cyberspace.CyberspaceScale +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import org.jetbrains.compose.resources.stringResource + +private val AVATAR_THUMBNAIL_SIZE = 96.dp + +/** + * A cyberspace avatar (`CYBERSPACE_V2.md` §8.10 kind 11333), which is an SNO + * payload in a replaceable container. + */ +@Composable +fun SnoAvatarCard( + payload: SnoPayload, + eventId: String, + name: String?, + onClick: (() -> Unit)? = null, +) { + val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp) + + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + SnoThumbnail( + payload = payload, + eventId = eventId, + size = AVATAR_THUMBNAIL_SIZE, + contentDescription = name ?: payload.name.ifBlank { null }, + ) + + Spacer(Modifier.width(12.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = name ?: payload.name.ifBlank { stringResource(Res.string.sno_avatar_title) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.sno_object_details, payload.vertexCount, payload.faceCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // An avatar's `unit` is what it was priced on (§8.10 pays for reach + // as well as detail), so it is the one card where the scale is not + // only informative but the reason the work came out as it did. + Text( + text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * The avatar of someone who has not published a shape. + * + * §8.10 makes a `kind 11333` with empty content the default avatar: no + * geometry, no work owed, and what everyone is until they adopt something. + * Drawing nothing for it is correct and unhelpful — the note disappears out of + * the feed, which reads as a fault — so it is drawn as the wireframe + * icosahedron the reference puts in its place, which is also a shape nobody + * could mistake for one somebody made. + */ +@Composable +fun SnoAvatarDefaultCard(eventId: String) { + Row( + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SnoThumbnail( + payload = SnoDefaultAvatar.payload, + eventId = eventId, + size = AVATAR_THUMBNAIL_SIZE, + contentDescription = stringResource(Res.string.sno_avatar_default), + ) + + Spacer(Modifier.width(12.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.sno_avatar_default), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.sno_avatar_default_details), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * What stands in for an avatar a client may not draw. + * + * `CYBERSPACE_V2.md` §8.10 is normative here: "a client MUST NOT draw an avatar + * event that is not paid, or that carries content it cannot read". An avatar is + * the one thing in cyberspace that lands on other people's screens whether they + * asked for it or not, so its size and its detail are paid for in proof of work + * on the event that publishes it, and a client draws nothing it cannot verify + * has paid. + */ +@Composable +fun SnoAvatarUnpaidCard() { + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = stringResource(Res.string.sno_avatar_unpaid), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt new file mode 100644 index 0000000000..a8614172b1 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt @@ -0,0 +1,138 @@ +/* + * 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.commons.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.sno_object_details +import com.vitorpamplona.amethyst.commons.resources.sno_object_scale +import com.vitorpamplona.amethyst.commons.resources.sno_object_title +import com.vitorpamplona.amethyst.commons.resources.sno_object_unreadable +import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.quartz.cyberspace.CyberspaceScale +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import org.jetbrains.compose.resources.stringResource + +private val THUMBNAIL_SIZE = 96.dp + +/** + * A Simple Nostr Object in a feed or a thread (DECK-0003 kind 33331). + * + * The geometry is the event, so there is nothing to fetch and nothing to + * expand: the card draws what it already has. Tapping opens it where it can be + * turned. + * + * The scale line is not decoration. §1.6's `unit` is the only field separating + * two objects with byte-identical geometry, and at `0` and `40` those two are a + * molecule and a mountain; a card that prints the vertex count for both has + * said nothing about the difference. [CyberspaceScale] turns the exponent into + * a length. + * + * @param footnote one more line under the scale, for something the container + * knows and the payload does not — a shard's place, say. + */ +@Composable +fun SnoObjectCard( + payload: SnoPayload, + eventId: String, + onClick: (() -> Unit)? = null, + footnote: String? = null, +) { + val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp) + + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + SnoThumbnail( + payload = payload, + eventId = eventId, + size = THUMBNAIL_SIZE, + contentDescription = payload.name.ifBlank { null }, + ) + + Spacer(Modifier.width(12.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = payload.name.ifBlank { stringResource(Res.string.sno_object_title) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.sno_object_details, payload.vertexCount, payload.faceCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + footnote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** + * What a client shows instead of an object it may not draw. + * + * DECK-0003 §3.1: a client that receives an event whose content fails §1.9 MUST + * NOT render it and SHOULD say why rather than failing silently — hence the + * rule number, which is the whole reason the parser hands one back. + */ +@Composable +fun SnoObjectUnreadableCard(rule: String) { + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = stringResource(Res.string.sno_object_unreadable, rule), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt index de4e23f972..d23c4331cc 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.image +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap import coil3.Image import coil3.asImage import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage @@ -36,3 +38,5 @@ actual fun base64DataUriToCoilImage(dataUri: String): Image { val encoded = SkiaImage.makeFromEncoded(Base64.decode(payload)) return Bitmap.makeFromImage(encoded).asImage(true) } + +actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap() diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt index 0756390bef..097506fb80 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.image +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap import coil3.Image import coil3.asImage import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage @@ -29,3 +31,5 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Image actual fun PlatformImage.toCoilImage(): Image = toSkiaBitmap().asImage(true) actual fun base64DataUriToCoilImage(dataUri: String): Image = Base64Image.toPlatformImage(dataUri).toCoilImage() + +actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap() diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt new file mode 100644 index 0000000000..34b6225ef3 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt @@ -0,0 +1,59 @@ +/* + * 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.commons.sno + +import androidx.compose.ui.graphics.ImageBitmap +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The path a drawn object actually takes on this platform: rasteriser to + * [PlatformImage] to a Compose image. Compiling proves the expect/actual lines + * up; this proves the pixels survive the trip. + */ +class SnoComposeImageBridgeTest { + private val json = + """{"v":2,"name":"tetra","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]]}""" + + @Test + fun aRasterSurvivesTheTripToCompose() { + val payload = SnoParser.parse(json).payloadOrNull() + assertNotNull(payload) + + val size = 48 + val pixels = SnoRasterizer.render(payload, size, size, yawDegrees = 0f, pitchDegrees = 0f, background = 0xFF000000.toInt()) + assertEquals(0xFFFF0000.toInt(), pixels[(size / 2) * size + size / 2], "the raster itself should be red in the middle") + + val bitmap: ImageBitmap = PlatformImage.create(pixels, size, size).toComposeImageBitmap() + assertEquals(size, bitmap.width) + assertEquals(size, bitmap.height) + + val readBack = IntArray(size * size) + bitmap.readPixels(readBack) + assertEquals(0xFFFF0000.toInt(), readBack[(size / 2) * size + size / 2], "and still red after the conversion") + assertTrue(readBack.any { it == 0xFF000000.toInt() }, "the background should survive too") + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt new file mode 100644 index 0000000000..051d95bf2c --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt @@ -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.commons.sno + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * What Coil's memory cache is allowed to treat as the same picture. + * + * The interesting case is the one DECK-0003 §1.3a creates: an object whose + * `colors` index a palette in another event is drawn against the built-in until + * that event arrives (§1.3b), so a single event id produces two different + * pictures. A key that names only the id would serve the first of them forever. + */ +class SnoFetcherKeyTest { + private fun payload(colors: String): SnoPayload = + SnoParser + .parse( + """{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":$colors,"faces":[[0,1,2]]}""", + ).payloadOrNull()!! + + private fun render(payload: SnoPayload) = SnoObjectToRender(payload, eventId = "abc", size = 96) + + @Test + fun theSameObjectInTheSameViewIsTheSamePicture() { + assertEquals(render(payload("[238,238,238]")).cacheKey, render(payload("[238,238,238]")).cacheKey) + } + + @Test + fun repaintingTheSameEventInAnotherPaletteIsADifferentPicture() { + assertNotEquals( + render(payload("[238,238,238]")).cacheKey, + render(payload("[225,225,225]")).cacheKey, + "the same id in different colours must not be served from one cache entry", + ) + } + + @Test + fun aColourOnAFaceCountsToo() { + // §1.4a's facecolors replace the fill without touching `colors`, so a + // key built from the vertex colours alone would miss them entirely. + val plain = payload("[238,238,238]") + val flat = + SnoParser + .parse( + """{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]],"facecolors":[225]}""", + ).payloadOrNull()!! + assertNotEquals(render(plain).cacheKey, render(flat).cacheKey) + } + + @Test + fun theViewAndTheSizeStillSeparatePictures() { + val p = payload("[238,238,238]") + assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 192).cacheKey) + assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 96, yawDegrees = 90f).cacheKey) + assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "def", 96).cacheKey) + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt new file mode 100644 index 0000000000..13533a9532 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt @@ -0,0 +1,171 @@ +/* + * 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.commons.sno + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.compose.setSingletonImageLoaderFactory +import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The card actually draws, in real Compose. + * + * Everything under this — the parser, the rasterizer, the image bridge — has + * its own tests, and all of them passed while two defects sat in the Compose + * layer above: a detail line that clipped mid-word because its column had no + * width to work with, and a viewer that rasterised on the composition thread. + * Compiling proved nothing about either. This renders the card offscreen + * through `ImageComposeScene` (no display, no device) and asserts the object + * reached the screen through Coil, the fetcher and the platform image bridge. + */ +class SnoObjectCardRenderTest { + /** A flat gold triangle: one colour, so it is unmistakable in the pixels. */ + private val gold = + """{"v":2,"name":"Gold","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[249,249,249],"faces":[[0,1,2]]}""" + + @Test + fun theCardDrawsTheObject() { + val payload = SnoParser.parse(gold).payloadOrNull() + assertTrue(payload != null, "fixture did not parse") + + val width = 700 + val height = 260 + val scene = ImageComposeScene(width = width, height = height, density = Density(2f)) + try { + scene.setContent { + setSingletonImageLoaderFactory { ctx: PlatformContext -> + ImageLoader + .Builder(ctx) + .components { + add(SnoFetcher.Factory) + add(SnoFetcher.SKeyer) + }.build() + } + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + Column(Modifier.fillMaxSize()) { + SnoObjectCard(payload = payload!!, eventId = "render-test", onClick = {}) + } + } + } + } + + // Coil resolves off the composition, so the first frames are the + // placeholder. Poll rather than sleeping a fixed amount. + // `return@repeat` would be a continue, not a break, and the + // assertion would then read whatever the last frame happened to be. + var goldPixels = 0 + for (poll in 0 until POLLS) { + val pixels = scene.render().toPixels(width, height) + goldPixels = pixels.count { it.isGold() } + if (goldPixels > 0) break + Thread.sleep(POLL_MILLIS) + } + + assertTrue( + goldPixels > 200, + "the object should have reached the card through Coil and the image bridge; found $goldPixels gold pixels", + ) + } finally { + scene.close() + } + } + + @Test + fun theViewerDrawsTheObject() { + // The viewer has its own composition path — BoxWithConstraints sizing, + // an off-thread raster held across angles, and a graphicsLayer for zoom + // and pan. None of that is exercised by the card's path. + val payload = SnoParser.parse(gold).payloadOrNull() + assertTrue(payload != null, "fixture did not parse") + + val side = 400 + val scene = ImageComposeScene(width = side, height = side, density = Density(2f)) + try { + scene.setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + SnoObjectViewer( + payload = payload!!, + eventId = "viewer-test", + modifier = Modifier.size(200.dp), + ) + } + } + } + + var goldPixels = 0 + for (poll in 0 until POLLS) { + val pixels = scene.render().toPixels(side, side) + goldPixels = pixels.count { it.isGold() } + if (goldPixels > 0) break + Thread.sleep(POLL_MILLIS) + } + + assertTrue(goldPixels > 200, "the viewer should have drawn the object; found $goldPixels gold pixels") + } finally { + scene.close() + } + } + + /** Palette index 249 is `#ffd300`, which nothing else on the card is. */ + private fun Int.isGold(): Boolean { + val r = (this shr 16) and 0xFF + val g = (this shr 8) and 0xFF + val b = this and 0xFF + return r > 0xE0 && g in 0xB0..0xF0 && b < 0x40 + } + + /** The rendered frame as ARGB, through PNG so no Skia pixel layout is assumed. */ + private fun Image.toPixels( + width: Int, + height: Int, + ): IntArray { + val png = encodeToData(EncodedImageFormat.PNG)!!.bytes + val decoded = ImageIO.read(ByteArrayInputStream(png)) + val out = IntArray(width * height) + decoded.getRGB(0, 0, width, height, out, 0, width) + return out + } + + companion object { + private const val POLLS = 40 + private const val POLL_MILLIS = 50L + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index 31af30e2d2..43792cfb50 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -40,6 +40,7 @@ import coil3.svg.SvgDecoder import com.vitorpamplona.amethyst.commons.service.image.Base64Fetcher import com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher import com.vitorpamplona.amethyst.commons.service.image.ThumbHashFetcher +import com.vitorpamplona.amethyst.commons.sno.SnoFetcher import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import okhttp3.Call import okio.Path.Companion.toOkioPath @@ -63,9 +64,11 @@ object DesktopImageLoaderSetup { add(SkiaGifDecoder.Factory()) add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) + add(SnoFetcher.Factory) add(ThumbHashFetcher.Factory) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) + add(SnoFetcher.SKeyer) add(ThumbHashFetcher.TKeyer) }.build() diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md new file mode 100644 index 0000000000..401442a0f4 --- /dev/null +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -0,0 +1,418 @@ +# Cyberspace §7 — opening a region bag + +_Written 2026-09-22, after DECK-0003 (SNO) shipped its reader and left `kind 33330` +closed. This is the plan for opening it._ + +Source: [`CYBERSPACE_V2.md`](https://github.com/arkin0x/cyberspace/blob/master/CYBERSPACE_V2.md) +§2 (coordinates), §4 (Cantor trees), §7 (location-based encryption and discovery), +§8.6 (the bag event). Reference implementations: `arkin0x/cyberspace-cli` (Python) and +`arkin0x/cyberspace-cli-js` (TypeScript, published as `cyberspace-core`), plus +`arkin0x/ONOSENDAI` as the only client that does this today. + +Decisions taken up front: **all three layers ship** (protocol, CLI, UI), and **every +sweep is a tap** — no background CPU is ever spent on a stranger's puzzle, however +cheap it looks. + +--- + +## 1. What this reverses + +`amethyst/plans/2026-09-21-deck-0003-sno.md` deferred bags as decision D2, on a +reason that turned out to be wrong twice over. + +The first version said opening a bag needs "§7.4 discovery scanning", and leaned on +Amethyst having no position in cyberspace to scan from. §7.7 says the opposite in as +many words: + +> The seeker's own position never enters this cost, because §7.1 makes looking and +> walking equivalent: a region key can be computed for any coordinate without +> traveling there. + +Scanning your own neighbourhood is the opportunistic half, and §7.7 is blunt about +what it is worth alone: "a bag is found only by intentionally deep scanning and/or +wandering. With no additional information, any given bag is equally likely to be at +any point in the full 2^256 coordinate space: an impossibly hardened secret." +ONOSENDAI's own code agrees — `destinationKeys.ts` opens with "Things are hidden in +cubes, so that key almost never fits a lock." + +What finds a bag is a **hint**: the hider publishes an aligned box, and a seeker +sweeps the candidate regions inside it. A client with no avatar, no position and no +movement chain can do that. + +The second wrong reason was that it would cost too much. It does not, at the heights +anyone actually hides at. §3 measures it. + +## 2. What a bag is, end to end + +1. A hider picks a coordinate and a height `h`, and derives the region key: three + per-axis Cantor roots of the aligned subtrees at `h` (§4.5, §4.6), combined by + nested Cantor pairing into `region_n` (§4.7), then + `key = sha256(region_n)` and `lookup_id = sha256(key)` (§7.2). +2. They encrypt everything they have hidden in that region — a JSON array of nostr + events — with AES-256-GCM under that key, and publish it as a `kind 33330` + addressable by `d = lookup_id` (§7.6, §8.6). +3. They MAY attach a `hint` tag naming an aligned box the region lies in, one height + per axis (§7.7). +4. A seeker sweeps the box: for each candidate region derive the key, hash to the + `lookup_id`, and ask a relay for a bag with that `d`. Found, decrypt, verify the + items, render by inner kind. + +The two-layer hash is the whole trick: `lookup_id` is safe to publish because it is +a hash *of* the key, so seeing it buys nothing without the region preimage. + +## 3. What it costs, measured + +A region key is three `O(2^h)` folds of BigInts that double in width at every level, +plus one combine. Measured on this JVM against the same leaf-by-leaf fold +`cyberspace-core` uses: + +| bag height `h` | one axis root | one combine + 2 SHA-256 | +|---:|---:|---:| +| 5 | 0.03 ms | 0.033 ms | +| 8 | 0.18 ms | 0.48 ms | +| 12 | 9.2 ms | 32 ms | +| 16 | 555 ms | 1,873 ms | + +Per key (three axes plus a combine) that is 1.2 ms at h8 and 2.1 s at h16, against +the spec's own figures in §7.7 of 1.3 ms and 816 ms on one desktop core — within 2 +to 3x, which is what a JVM `BigInteger` (Karatsuba and Toom-Cook, no FFT) should +give against C. + +**A sweep decomposes per axis.** The axes are independent all the way to the combine +(§4.7), so a box of gap `G` costs `3 · 2^(G/3)` tree builds and `2^G` combines, not +`2^G` full keys. The combine term dominates above h8: + +| | gap 0 | gap 6 | gap 12 | gap 18 | +|---|---|---|---|---| +| **h5** | instant | 2 ms | 0.14 s | 9 s | +| **h8** | 2 ms | 31 ms | 2 s | 2 min | +| **h12** | 60 ms | 2 s | 2 min | 2.3 h | +| **h16** | 3.5 s | 2 min | 2 h | — | + +This agrees with §7.7's own table (gap 12 at h≤8 is "seconds", gap 24 is "hours"), +and it says the affordable corner is most of the corner anyone uses. Android will be +slower — measure it before quoting a number in the UI. + +**The growth ratio is ~2.2x per height, not 2x**, in the spec's figures and in the +measurement, because a pairing doubles the leaf count *and* the operand width. A +`GROWTH_RATIO_FLOOR` of that shape is what ONOSENDAI's own calibration fits. + +## 4. Layer 1 — quartz, protocol only + +New package `quartz/.../cyberspace/`, beside the `CyberspaceScale` and +`CyberspaceCoordinate` the SNO work already put there. + +| Piece | What | Pinned by | +| --- | --- | --- | +| `CyberspaceCoordinate` | §2.2 in full: interleave and de-interleave X/Y/Z/plane. Fixed 256 bits, so a `ByteArray(32)`; no bignum, no allocation per axis | §9.8's six vectors round-tripped, §7.7's three hint vectors | +| `CantorTree` | §4.5/§4.6: `cantorPair`, `alignedBase`, `subtreeRoot(base, height)`, with the reference's `maxComputeHeight` refusal | `cyberspace-cli`'s own roots, through the CLI harness | +| `RegionKey` | §7.2's two hashes over `int_to_bytes_be_min` | the same | +| `CyberspaceHint` | §7.7 tag parse with every MUST: arity, lowercase hex, aligned base, `H` in `[0,85]`, canonical integers, `H >= h`, sector-tag agreement — and a malformed hint is **absent**, never a rejection of the bag | §7.7's three golden vectors | +| `CyberspaceBagEvent` | kind 33330: `d`/`h`/`encrypted`/`hint`/sector tags, AES-256-GCM open, §7.6's plaintext rules | round trip against the reference CLI's `encrypt` | +| `RegionSweep` | the axis-decomposed enumeration, as a cold sequence a caller pulls with its own budget | a unit test that a gap-0 hint is one candidate and equals the direct derivation | + +Two things need an `expect`/`actual`: arbitrary-precision integers (`java.math.BigInteger` +on `jvmAndroid`) and AES-256-GCM (`javax.crypto`). iOS gets neither for now and the +module declares it. + +**The rules that are easy to get wrong**, all normative, all worth a test each: + +- A failed decryption "MUST NOT be treated as an error in the bag" (§7.6) — it means + only that this reader lacks the key. +- Items MAY be unsigned. A signed item must have the canonical id and a verifying + signature or it is dropped, **and only it**: "one corrupt or forged item says + nothing about the others". An unsigned item's `pubkey` is a claim and MUST NOT be + shown as verified authorship. +- Placement is attributable to the bag's author; authorship of an item only to the + item's own pubkey, and only when signed. +- An item's `C` tag must lie inside the bag's region, and a reader MAY drop one that + does not. +- A reader that does not understand an item's kind skips it and renders the rest. + +## 5. Layer 2 — `amy` + +Thin assembly over Layer 1, which is where interop is proven. The SNO conformance +harness already runs 11 of 11 against the reference implementations; these extend it. + +``` +amy cyberspace coord # x, y, z, plane +amy cyberspace region # region_n, key, lookup_id +amy cyberspace hint # box, candidates, estimated cost +amy cyberspace sweep [--budget] # the lookup_ids, or the key when found +amy cyberspace open --key # decrypt, verify items, dump +``` + +`region` and `open` are directly diffable against `cyberspace-cli`; `hint` against +`hint-reference.py`'s golden vectors. No UI decision touches any of it. + +## 6. Layer 3 — Amethyst + +Only for a bag someone put in front of you: quoted in a note, or in a thread. Nothing +sweeps the network on its own, and nothing sweeps on arrival. + +A `kind 33330` in a feed renders a card that has already parsed its hint and priced +it, and says so: *"Hidden in a 4,096-region box — about 2 minutes of searching."* +A button starts it. While it runs, progress and a cancel. Found, the items render +through the cards that already exist: `3330` through `SnoObjectCard`, `1` as a note, +anything else skipped. + +**Every sweep is a tap, including a gap-0 hint** that the spec itself calls "a +destination the seeker can compute or walk to directly, not a search" and which costs +60 ms. The consistency is the point: a reader never learns that some bags open +themselves, so a hostile bag cannot hide inside that expectation. + +**Price before you spend.** The budget comes from the `hint` tag and the `h` tag +before a single tree is built, and it is a hard cap, because a bag can carry a gap-30 +hint precisely to burn a day of a reader's battery. A sweep that would exceed the cap +is not offered as a button at all — it is reported as out of reach. + +## 7. Not in scope + +- §7.4 position scanning. We have nowhere to stand, and §7.7 says it is not how + things are found. +- §4–§6 movement chains, §8.3 spawn, DECK-0001 hyperspace. That is a client *for* + cyberspace; this is a Nostr client reading an event. +- Publishing, hiding, or holding a region (§7.8). Read-only, like the rest of SNO. +- §9.7's GPS mapping: 96-digit decimal arithmetic with a hand-rolled deterministic + trig series, carried so every client agrees where a place on Earth is. Amethyst has + no Earth to agree about. + +## 8. Order + +1. **Coordinates.** `CyberspaceCoordinate` in full, §9.8 and §7.7 vectors. No bignum + and no new dependency: an 85-bit axis splits at 64 into a 21-bit high half and an + unsigned low Long, which is enough for the bit layout, the plane, the sector shift + of §10 and `alignedBase` — and `alignedBase` is exactly what a sweep enumerates, + so this is the foundation the rest stands on. **Done**, 8 tests, every vector the + spec publishes. + + It does *not* improve the shard card, which was the claim when this was written. + The thought was to upgrade the `C` tag line from a plane to a place, but the only + human-readable thing a decode adds is the sector triple, and at 55 bits an axis + that is a fifty-character string for a 12 cm cube — worse than the abbreviated + coordinate already there, which at least copies. Two shards in one sector would be + worth saying; there are 21 reachable `3330`s and one author, so there is nothing + to compare. Revisit when there is. +2. **Cantor + region key**, with the big integer and the height refusal. + `amy cyberspace region`, diffed against `cyberspace-cli`. **Done** — see §9. +3. **Hints.** Parse, validate, price. `amy cyberspace hint`. The three golden + vectors. **Done** — see §10. +4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace + open`, round-tripped against the reference CLI's `encrypt`. **Done** — see §11. +5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. **Done** — + see §11. +6. **The card**, last, once every number it quotes is measured on a device. + **Done** — see §12, which measures them on the device at the tap rather than + baking in a constant. + +Steps 1 to 5 have no product risk and every one of them is diffable against a +reference implementation. Step 6 is the only judgement call, and it is small. + + +## 9. Step 2 as built + +`CantorTree`, `RegionKey`, `UBigInt`, `amy cyberspace coord|region`. The +conformance harness is now 12 of 12, the new section comparing **16 region keys +and their coordinate decodes** against `cyberspace-cli` — four coordinates +(§9.8's london, nyc and origin, plus §7.7's ideaspace point) at heights 0, 1, 4 +and 8. Amethyst derives the keys the rest of the network derives. + +### The big integer, and why there are two of them + +The plan said "an `expect`/`actual` over `java.math.BigInteger`". The first +attempt went the other way — one portable implementation everywhere — on the +argument that a region key is a **consensus value**, since §7.2 turns it into an +AES key, so two implementations is two chances to disagree and an object that +opens on a desktop and not on a phone. + +Measurement reversed that. Portable Kotlin came in **3 to 10 times slower** than +`java.math.BigInteger` on the operands a Cantor tree reaches, because +`BigInteger.multiplyToLen` is a HotSpot intrinsic and the JDK adds Toom-Cook +above a few hundred limbs. §7's whole feasibility is a number, and that factor +is the difference between a search a reader waits for and one they abandon. + +So: `UBigInt` is an `expect class`, aliased through a thin wrapper to +`java.math.BigInteger` on `jvmAndroid`, and backed by `PortableUBigInt` on +`nativeMain` — which covers Apple and Linux together, so there are two actuals +and not three. A wrapper rather than a `typealias` for one reason: +`toMinimalBytes`. The reference hashes `int_to_bytes_be_min`, and +`BigInteger.toByteArray()` is two's complement, so it grows a `0x00` sign byte +whenever the top bit is set — half of all numbers — and aliasing would have put +that byte into a SHA-256 and produced a key nobody else derives. + +**What makes two implementations safe is that the disagreement is testable, and +tested.** `PortableUBigIntDifferentialTest` runs every operation against +`java.math.BigInteger` over random inputs at fifteen widths from 0 to 352,000 +bits, straddling the Karatsuba threshold in both directions, and a ninth test +asserts the two *actuals* agree with each other on the same inputs — including +the bytes, which is the one place aliasing would have gone wrong silently. +`CantorTreeBenchmark` then folds a whole subtree both ways and compares the +roots, which is where an off-by-one in the fold's stack would live rather than +in the arithmetic. + +### What it costs, on the shipped path + +The §3 table was measured on `java.math.BigInteger`, which is what now ships on +JVM and Android, so it stands. Apple and Linux pay the portable multiplier on +top; nothing there opens a bag yet. + +### Corrections to §3 worth carrying forward + +The earlier measurement said a sweep decomposes per axis, `3 · 2^(G/3)` tree +builds and `2^G` combines. Building it confirmed the decomposition and also that +**the combine dominates above about height 8** — a combine at height 12 is +roughly ten times an axis root at the same height, because it multiplies two +numbers the size of the root rather than folding up to one. A budget model that +prices a sweep by its tree builds will under-quote badly; price it by `2^G` +combines and add the trees. + + +## 10. Step 3 as built + +`CyberspaceHint`, `amy cyberspace hint`, and a harness section that runs §7.7's +three golden vectors through the CLI and diffs them against the spec's own +`hint-reference.py`. The harness is 13 of 13. + +The vectors pin more than a parser. Each states a point, a bag height and three +hint heights, then gives the coordinate the aligned base must come out as and +the sector tags the bag must carry — so building a hint from the point has to +reproduce the published tag byte for byte, and `london_h5_box11`, +`london_h5_x_exact` and `ideaspace_h8_y_open` all do. + +### What the type says, and why + +A hint is the hider's **difficulty knob**, so the number that matters is +`gapBits` — `(Hx - h) + (Hy - h) + (Hz - h)` — and it is reported as an exponent +rather than a count because it runs to 255 when all three axes are left open and +nothing holds `2^255`. §7.7's own table reads in those terms: 12 is seconds, 24 +is hours, 30 or more is "days to never". + +`axisTrees` is reported apart from `candidates`, and the asymmetry is the point. +A sweep needs one Cantor tree per distinct base *per axis* and then one combine +per candidate, so a sector-only hint on a height-5 bag is a hundred million +trees against `2^75` combines: both say hopeless, only one of them can say it +with a number. A budget that prices a sweep by its trees under-quotes by the +same margin §9 warned about. + +### The rule that shapes the API + +§7.7: "A `hint` tag that breaks any rule above MUST be treated as absent... +A bad hint never invalidates the bag, because hints are advisory metadata about +where to look; whether a bag is valid is decided by §7.2 and §7.6 alone." + +So `read` returns null and never throws, for every one of: wrong arity, bad hex, +uppercase hex, an unaligned base, a height outside `[0, 85]`, a non-canonical +integer (`"011"` is not `"11"`), a height below the bag's `h`, and — the one the +spec states as a publisher rule without saying what a reader does — more than +one `hint` tag, which is read the same way because a bag whose author cannot be +read literally is exactly the case §7.7's remedy is for. + +The canonical-integer rule is worth keeping for the same reason the aligned base +is: both exist so that two hiders who hint the same box publish the same bytes, +and a reader that accepts `"05"` alongside `"5"` lets one box have two +spellings and breaks comparison by equality. + +## 11. Steps 4 and 5 as built + +`CyberspaceBagEvent` and `RegionSweep` in quartz, `amy cyberspace open` and +`amy cyberspace sweep` over them, and two more harness sections. The harness is +**15 of 15**. + +### What the two new sections actually prove + +Everything before this diffed a *number* against the reference — a key, a +lookup id, a hint tag. These diff a **ciphertext**, which is the only test that +catches a chain that agrees at every step and still cannot open a bag. +`cyberspace-cli` derives the region key with `location_encryption`, seals the +plaintext with `encrypt_with_location_key`, and writes the §8.6 tags with +`make_encrypted_content_event`. `amy` is handed the event and a coordinate and +has to reach the same 32 bytes: §2.2's interleave, §4.7's three Cantor roots, +§7.2's two hashes, §7.6's `nonce || ciphertext || tag`. Section 8 then takes the +coordinate away and makes it find the same bag from its hint box alone. + +The box in section 8 comes from the reference's own `coord_to_xyz` / +`xyz_to_coord` rather than from ours, so a disagreement about alignment shows up +as a bag that is not in the box we were handed — not as a test grading its own +arithmetic. + +### The two design decisions worth recording + +**`open` exits 0 on the wrong key.** §7.6: "A failed decryption therefore means +only that the reader does not hold this region's key; it MUST NOT be treated as +an error in the bag." So a wrong key is a verdict (`opened: false`), the way +`sno parse` reports an invalid payload, and the harness asserts the exit code as +well as the field. What *does* fail is a bag that cannot be attempted at all: an +unknown `version` (§8.6 says ignore it) or no `aes-256-gcm` payload to try. + +**`sweep` refuses before it spends.** The gap is read from the `hint` and `h` +tags and checked against `--max-gap` (default 20) before a single tree is built, +and the refusal quotes the exponent and names the flag that would buy it. A +harness case pins it: a gap-33 hint is declined rather than swept. This is the +same shape the card needs, which is why it lives in the CLI first — a budget +that can be tested in a shell script is a budget that can be trusted in a feed. + +### A shell trap worth remembering + +`jq`'s `//` treats `false` as empty, so `.opened // "null"` turns a correct +`false` into `"null"` and fails a passing test. Read booleans with a plain +`jq -r .field`. + +## 12. Step 6 as built + +`BagSweep` in `commons/cyberspace/` (headless: the quote, the budget and the +cold flow) and `RenderCyberspaceBag` in `amethyst/ui/note/types/` (the card), +wired into `NoteCompose` and `ThreadFeedView`, with kind 33330 routed through +`EventCache`'s addressable path. Nine tests in `commons`. + +### Pricing without a constant + +§3 of this plan ended with "Android will be slower — measure it before quoting a +number in the UI". What shipped measures it **at the moment of the tap, on the +device that is about to pay**, which is the same answer without a constant that +goes stale on the next handset. The order is: + +1. **Free, while the row composes.** `BagSweep.quote` reads two tags and does + three subtractions. A box past `MAX_GAP_BITS` (20) or a bag deeper than + `MAX_BAG_HEIGHT` (12, the ceiling ONOSENDAI puts on its own discovery scan) + is reported as out of reach and never becomes a button. No Cantor tree is + built for a hint the reader has already declined — a test pins that the flow + emits nothing at all in that case. +2. **One candidate, timed.** On the tap, the box's own base region is derived + and the elapsed time recorded. That candidate is never wasted work: a gap-0 + hint names exactly it, so the measurement *is* the search for a destination + hint. +3. **The estimate, then the rest.** `perCandidate × candidates` against a + two-minute budget. It over-quotes — the first candidate is three axis roots + plus a combine and every later one is a combine, so by about 4x at shallow + heights and 2x at deep ones — and over-quoting is the safe direction for a + number whose only job is to decide whether to spend somebody's battery. + +A slower phone therefore refuses boxes a faster one accepts. That asymmetry is +correct: the cost is the reader's, so the reader's hardware should decide. + +### What the card says, and what it never does + +Every sweep is a tap, **including a gap-0 hint**, which §7.7 itself calls "a +destination the seeker can compute or walk to directly, not a search" and which +costs about a frame. A reader who learned that some bags open themselves would +have learned an expectation a hostile bag could hide inside, so the one +candidate is offered exactly like the million. A test pins it. + +Scrolling the row away cancels: the flow is cold over a cold sequence and the +card disposes its job, so a sweep never outlives the card that asked for it. + +Items render through the cards that already exist — a `3330` shard through +`SnoObjectCard` (palette fetched by `WithSnoPalette`, as a standalone object +would be), a `kind 1` as its text, anything else named and skipped, because +§7.6 says a reader that does not understand an item's kind "skips it and renders +the rest". The attribution is the non-obvious part and §7.6 fixes it: placement +belongs to the bag's author, authorship only to a signed item's own pubkey. An +unsigned item therefore carries a line saying its author is a claim, rather than +borrowing either name. + +### One correction carried back into quartz + +`SnoShardEvent`'s KDoc said Amethyst "implements none of it" and that a reader +without the key sees base64 and nothing else. Both were true when it was +written. The class now points at `RegionSweep` and says what actually decides +whether a bag opens — whether its own hint prices the search into reach. diff --git a/quartz/plans/README.md b/quartz/plans/README.md index da40e77b17..e56dc4d372 100644 --- a/quartz/plans/README.md +++ b/quartz/plans/README.md @@ -11,6 +11,7 @@ _Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 | [2026-07-04-small-req-floor.md](2026-07-04-small-req-floor.md) | Small-REQ dispatch floor: decomposed, inline fast path tried and reverted (no wire-level win); floor is transport-side. | | [2026-08-13-gpu-pow-mining.md](2026-08-13-gpu-pow-mining.md) | GPU NIP-13 mining declined (ARMv8 has SHA-256 in silicon, mobile GPUs do not). Midstate is ~3x on JVM targets; Android hinges on Conscrypt per-digest JNI cost, still unmeasured. created_at refresh while mining shipped. | | [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stages 0-4 done (mdk interop reference, app_data_dictionary, identity proof v2, the six group components, transport corrections); lifecycle + branch selection landed. | +| [2026-09-22-cyberspace-region-bags.md](2026-09-22-cyberspace-region-bags.md) | Open `kind 33330` region bags: §2 coordinates, §4 Cantor roots, §7.2 region keys, §7.7 hint sweeps, §7.6 item verification. Reverses SNO's D2 — §7.7 says a seeker's position never enters the cost, and a key is 1.2 ms at h8 against the spec's own 1.3 ms. Three layers: quartz, `amy`, and a tap-to-search card. | ## Archived (shipped) | Plan | Summary | diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt index 74c7621bc8..e311cd8aa1 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt @@ -128,6 +128,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + // --- Internal operations --- /** Derive Ed25519 public key from 32-byte seed. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt new file mode 100644 index 0000000000..46668ba924 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt @@ -0,0 +1,131 @@ +/* + * 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 + +import com.vitorpamplona.quartz.utils.bigint.UBigInt + +/** + * `CYBERSPACE_V2.md` §4.5 and §4.6: the Cantor number of an aligned subtree. + * + * A region is not a box somebody drew, it is an **aligned** subtree: its base is + * a multiple of `2^h` and it covers exactly `2^h` leaves, so the boundaries are + * fixed by arithmetic rather than by anyone's movement. That is the whole reason + * §7's location keys work — "Two people standing in the same neighbourhood will + * compute the same Cantor root without ever communicating, because they are both + * computing the root of the same aligned subtree." + * + * The root is that subtree's leaves paired bottom-up until one number is left. + * It is `O(2^h)` and there is no closed form; the numbers double in width at + * every level, so a root at height `h` runs to about `86 · 2^h` bits and the + * cost grows by roughly 2.2x per height — twice the leaves times wider operands. + * That is not an implementation detail to optimise away, it is the *point*: §7.1 + * makes the work the price of admission, and "looking and walking cost the same" + * only because this is expensive. + */ +object CantorTree { + /** + * The tallest subtree this will build, matching `cyberspace-core`'s + * `DEFAULT_MAX_COMPUTE_HEIGHT` and `cyberspace-cli`'s `max_compute_height`. + * + * Both references refuse rather than try, and so does this: one height past + * it is twice the leaves and a root twice as wide, and the difference + * between a request that takes a minute and one that takes the afternoon is + * a single integer a stranger chose. A caller that wants more says so. + */ + const val DEFAULT_MAX_COMPUTE_HEIGHT = 20 + + /** + * §4.6: `cantor_pair(a, b) = (a + b)(a + b + 1) / 2 + b`. + * + * A bijection on pairs of non-negative integers, which is what makes a root + * identify one region and no other. The halving is exact because `s(s + 1)` + * is a product of consecutive integers and therefore even. + */ + fun cantorPair( + a: UBigInt, + b: UBigInt, + ): UBigInt { + val sum = a + b + return (sum * (sum + UBigInt.ONE)).shiftRight(1) + b + } + + /** + * The root of the aligned subtree of [height] whose lowest leaf is [base]. + * + * Folded leaf by leaf against a stack of partial roots rather than a level + * at a time. The root is the same either way — a pairing at level `k` joins + * the same two subtrees in the same order whichever way the tree is walked — + * but a level at a time holds every leaf at once, a million of them at + * height 20, while the stack holds at most `height + 1` numbers, which + * together come to about one level's worth. + */ + fun subtreeRoot( + base: UBigInt, + height: Int, + maxComputeHeight: Int = DEFAULT_MAX_COMPUTE_HEIGHT, + ): UBigInt { + require(height >= 0) { "height must be >= 0" } + require(height <= maxComputeHeight) { "height $height exceeds maxComputeHeight $maxComputeHeight" } + if (height == 0) return base + + val values = arrayOfNulls(height + 1) + val levels = IntArray(height + 1) + var top = 0 + + val leaves = 1L shl height + for (i in 0 until leaves) { + var value = base + UBigInt.of(i) + var level = 0 + while (top > 0 && levels[top - 1] == level) { + top-- + value = cantorPair(values[top]!!, value) + level++ + } + values[top] = value + levels[top] = level + top++ + } + return values[0]!! + } + + /** + * §4.5: the height of the smallest aligned subtree holding both values — + * `bit_length(v1 XOR v2)`, which is how far up the tree they first meet. + */ + fun lcaHeight( + a: CyberspaceAxis, + b: CyberspaceAxis, + ): Int { + val high = a.high xor b.high + val low = a.low xor b.low + return if (high != 0L) Long.SIZE_BITS + bitLength(high) else bitLength(low) + } + + private fun bitLength(value: Long): Int { + var bits = 0 + var v = value + while (v != 0L) { + bits++ + v = v ushr 1 + } + return bits + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt new file mode 100644 index 0000000000..810f28bfb8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt @@ -0,0 +1,246 @@ +/* + * 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 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.serialization.json.JsonArray +import kotlin.io.encoding.Base64 + +/** + * One item out of an opened bag (`CYBERSPACE_V2.md` §7.6). + * + * [verified] is the only thing that decides whose words these are. §7.6: an item + * "MAY be signed. If it carries a `sig`, its `id` MUST be the canonical id + * (§8.2) and the signature MUST verify... An item without a `sig` is allowed, + * because some content is deliberately left unsigned; its `pubkey` is then a + * claim, and readers MUST NOT present it as verified." + * + * So placement is always attributable to the bag's author, and authorship of the + * content only to [Event.pubKey] and only when this is true. "A signed item + * written by one key and hidden by another is therefore shown as that author's + * content, placed here by the hider." + */ +@Immutable +class CyberspaceBagItem( + val event: Event, + val verified: Boolean, +) { + /** + * §7.6: an item MAY carry a `C` tag, "its exact coordinate (§2), which lets + * a client render it at a point rather than somewhere in the region." + */ + fun coordinate(): String? = event.tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1) +} + +/** What a bag turned out to hold, once its key was found (§7.6). */ +@Immutable +sealed class CyberspaceBagContents { + /** + * "A JSON array whose elements are nostr events, signed or unsigned. + * Readers MUST try this shape first, because a list of items is the shape + * clients render item by item." + */ + @Immutable + class Items( + val items: List, + /** How many elements were dropped for a bad id or signature. */ + val dropped: Int, + ) : CyberspaceBagContents() + + /** + * "Anything that is not a list of items, such as a text note or a file. Its + * interpretation is application-defined." + */ + @Immutable + class Opaque( + val bytes: ByteArray, + ) : CyberspaceBagContents() +} + +/** + * `CYBERSPACE_V2.md` §7.6 and §8.6 — content hidden at a place. + * + * A bag is one addressable event holding everything its author has hidden in one + * region at one height. The ciphertext is public and a relay will hand it to + * anyone; only someone who has computed that region's key can open it, "whether + * they computed it by moving into the region or by deriving it for the + * coordinate directly". That is §7.1's chalk on the sidewalk: not secrecy, but a + * thing you cannot read without paying the cost of being where it is. + * + * The `d` tag is the region's `lookup_id` — a hash of the key (§7.2) — so the + * address is safe to publish and buys nothing on its own. That is also what a + * §7.7 sweep queries on: derive a candidate region's key, hash it, ask for the + * bag with that `d`. + * + * **A bag that will not open is not a broken bag.** §7.6 is explicit: "An + * attempt with the wrong key fails at the GCM tag check and reveals nothing + * about the plaintext. A failed decryption therefore means only that the reader + * does not hold this region's key; it MUST NOT be treated as an error in the + * bag." So [open] returns null and a client says nothing. + */ +@Immutable +class CyberspaceBagEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The `d` tag: this region's `lookup_id` (§7.2), and the address a sweep asks for. */ + fun lookupId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) + + /** + * The `h` tag: "the height of the region whose key encrypts the content, + * which is the discovery radius of §7.3". Optional, and a hint is read + * against it. + */ + fun height(): Int? = tags.firstOrNull { it.size > 1 && it[0] == "h" }?.get(1)?.toIntOrNull() + + /** + * §8.6: "`version` names the rules of §7.6. A reader MUST ignore a bag whose + * version it does not know." + */ + fun isKnownVersion(): Boolean = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) == VERSION + + fun hint(): CyberspaceHint? = CyberspaceHint.read(tags, height()) + + /** + * The `encrypted` tag's payload, or null when the bag carries none or names + * a cipher this does not implement. + * + * §7.6 fixes the cipher: AES-256-GCM, a fresh 12-byte nonce, a 16-byte tag, + * `nonce || ciphertext || tag`, base64. Only the lookup and the key are + * normative across the protocol; the cipher is the convention the reference + * CLI and ONOSENDAI share, so a bag naming a different one is somebody + * else's format rather than a malformed bag. + */ + fun payload(): ByteArray? { + val tag = tags.firstOrNull { it.size > 2 && it[0] == "encrypted" } ?: return null + if (tag[1] != ALGORITHM) return null + return try { + Base64.decode(tag[2]) + } catch (_: Exception) { + null + } + } + + /** + * Open this bag with a region key, or null when the key is not this + * region's — which §7.6 says is not an error and reveals nothing. + * + * @param key the 32-byte `location_decryption_key` of §7.2. + */ + fun open(key: ByteArray): CyberspaceBagContents? { + if (!isKnownVersion()) return null + if (key.size != KEY_BYTES) return null + + val payload = payload() ?: return null + if (payload.size < NONCE_BYTES + TAG_BYTES) return null + + val nonce = payload.copyOfRange(0, NONCE_BYTES) + val sealed = payload.copyOfRange(NONCE_BYTES, payload.size) + val plaintext = + try { + AESGCM(key, nonce).decryptOrNull(sealed) + } catch (_: Exception) { + null + } ?: return null + + return read(plaintext) + } + + companion object { + const val KIND = 33330 + + /** §7.6's cipher, as the `encrypted` tag names it. */ + const val ALGORITHM = "aes-256-gcm" + + /** §8.6's `version` tag: the rules of §7.6. */ + const val VERSION = "2" + + const val KEY_BYTES = 32 + const val NONCE_BYTES = 12 + const val TAG_BYTES = 16 + + /** + * §7.6's two plaintext shapes, in the order it requires: "Readers MUST + * try this shape first, because a list of items is the shape clients + * render item by item." + */ + fun read(plaintext: ByteArray): CyberspaceBagContents = readItems(plaintext) ?: CyberspaceBagContents.Opaque(plaintext) + + /** + * A JSON array of nostr events, each verified on its own. + * + * §7.6: "a reader MUST drop an item that fails either check, **and only + * that item**, because one corrupt or forged item says nothing about the + * others." So a bad element costs itself and nothing else — and a + * plaintext that is not an array of events at all is not a list, which + * makes it opaque rather than broken. + */ + private fun readItems(plaintext: ByteArray): CyberspaceBagContents.Items? { + val text = plaintext.decodeToString() + if (text.trimStart().firstOrNull() != '[') return null + + val elements = + try { + KotlinSerializationMapper.json.parseToJsonElement(text) as? JsonArray + } catch (_: Exception) { + null + } ?: return null + + val items = mutableListOf() + var dropped = 0 + var understood = 0 + for (element in elements) { + val event = + try { + Event.fromJson(element.toString()) + } catch (_: Exception) { + // Not an event at all. A plaintext of arbitrary JSON is + // not "a list of items", so this is not a dropped item; + // it is evidence the whole shape is the other one. + return null + } + understood++ + if (event.sig.isBlank()) { + // "An item without a `sig` is allowed, because some content + // is deliberately left unsigned; its `pubkey` is then a + // claim." Kept, and marked. + items.add(CyberspaceBagItem(event, verified = false)) + } else if (event.verify()) { + items.add(CyberspaceBagItem(event, verified = true)) + } else { + dropped++ + } + } + if (understood == 0) return null + return CyberspaceBagContents.Items(items, dropped) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt new file mode 100644 index 0000000000..6d351690ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt @@ -0,0 +1,234 @@ +/* + * 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 + +/** Which of the two overlapping coordinate spaces a place is in (§2.4). */ +enum class CyberspacePlane { + /** Maps to physical reality through GPS (§9). */ + DATASPACE, + + /** No physical counterpart; purely abstract positions. */ + IDEASPACE, +} + +/** + * One 85-bit axis value, split at 64 bits because no primitive holds it. + * + * [high] is bits 64 to 84, so at most 21 bits and always positive. [low] is bits 0 + * to 63 as a raw Long, which is to say unsigned: an axis near the top of its range + * has its sign bit set and is not a negative number. Nothing here compares or adds + * axes, so that distinction stays contained; the moment arithmetic is needed — the + * Cantor trees of §4 — this becomes a big integer at the boundary and nowhere + * earlier, the same rule the SNO lattice follows. + */ +class CyberspaceAxis( + val high: Long, + val low: Long, +) { + /** + * The sector this axis falls in: the axis shifted right by 30 (§10). + * + * 85 bits less 30 is 55, so a sector index is one of the few things about a + * coordinate that does fit a Long, which is why the network writes them as + * decimal `X`, `Y`, `Z` and `S` tag values and why they are worth extracting + * when the axis itself is not. + */ + fun sector(): Long = (high shl (Long.SIZE_BITS - CyberspaceCoordinate.SECTOR_SHIFT)) or (low ushr CyberspaceCoordinate.SECTOR_SHIFT) + + /** + * This axis with its low [height] bits cleared: the base of the aligned subtree + * of that height containing it (§4.5, `base = (v >> h) << h`). + */ + fun alignedBase(height: Int): CyberspaceAxis { + if (height <= 0) return this + if (height >= CyberspaceCoordinate.AXIS_BITS) return CyberspaceAxis(0L, 0L) + if (height >= Long.SIZE_BITS) { + val keep = height - Long.SIZE_BITS + return CyberspaceAxis((high shr keep) shl keep, 0L) + } + return CyberspaceAxis(high, (low ushr height) shl height) + } + + override fun equals(other: Any?): Boolean = other is CyberspaceAxis && other.high == high && other.low == low + + override fun hashCode(): Int = high.hashCode() * 31 + low.hashCode() +} + +/** A place: three axes and the plane they are in (§2.1). */ +class CyberspacePoint( + val x: CyberspaceAxis, + val y: CyberspaceAxis, + val z: CyberspaceAxis, + val plane: CyberspacePlane, +) { + /** + * The `S` tag's value: the three sector indices joined by hyphens, in axis + * order, exactly as §7.7's golden vectors write it. + */ + fun sector(): String = "${x.sector()}-${y.sector()}-${z.sector()}" + + fun alignedBase(height: Int) = CyberspacePoint(x.alignedBase(height), y.alignedBase(height), z.alignedBase(height), plane) + + override fun equals(other: Any?): Boolean = other is CyberspacePoint && other.x == x && other.y == y && other.z == z && other.plane == plane + + override fun hashCode(): Int = ((x.hashCode() * 31 + y.hashCode()) * 31 + z.hashCode()) * 31 + plane.ordinal +} + +/** + * `CYBERSPACE_V2.md` §2: a place as a 256-bit integer, written as 32 lowercase + * bytes of hex. + * + * The three 85-bit axes are **interleaved** rather than packed, `XYZXYZ…` from the + * top with the plane bit at the bottom (§2.2), so that coordinates which are close + * in space share a bit prefix — which is what makes an aligned Cantor subtree (§4.5) + * a region everyone agrees on without communicating, and therefore what makes §7's + * location-based keys work at all. + * + * Bit 0 is the plane. Bits `1, 4, 7, …` are Z, bits `2, 5, 8, …` are Y and bits + * `3, 6, 9, …` are X, each 85 of them, which is `85 × 3 + 1 = 256` exactly. + * + * An item hidden at a place carries one of these in a `C` tag (§7.6); a hint names + * an aligned box with one (§7.7). + */ +object CyberspaceCoordinate { + /** 32 bytes, as §7.6 and §8 write it. */ + const val HEX_LENGTH = 64 + + /** Bits per axis (§2.1). */ + const val AXIS_BITS = 85 + + /** A sector is an axis shifted right by this (§10). */ + const val SECTOR_SHIFT = 30 + + /** True when this is a coordinate at all: 64 lowercase hex characters. */ + fun isWellFormed(hex: String): Boolean { + if (hex.length != HEX_LENGTH) return false + for (c in hex) { + val ok = (c in '0'..'9') || (c in 'a'..'f') + if (!ok) return false + } + return true + } + + /** + * The plane a coordinate names, or null when the string is not one. + * + * Bit 0 of the 256-bit integer, which is the low bit of the last hex digit. + * Kept separate from [decode] because it is the one part of a coordinate a + * client with no world can use on its own, and it costs a character. + */ + fun planeOf(hex: String): CyberspacePlane? { + if (!isWellFormed(hex)) return null + return if (nibble(hex, HEX_LENGTH - 1) and 1 == 1) CyberspacePlane.IDEASPACE else CyberspacePlane.DATASPACE + } + + /** The three axes and the plane, or null when the string is not a coordinate. */ + fun decode(hex: String): CyberspacePoint? { + if (!isWellFormed(hex)) return null + + var xHigh = 0L + var xLow = 0L + var yHigh = 0L + var yLow = 0L + var zHigh = 0L + var zLow = 0L + + for (i in 0 until AXIS_BITS) { + val z = bit(hex, 1 + i * 3) + val y = bit(hex, 2 + i * 3) + val x = bit(hex, 3 + i * 3) + if (i < Long.SIZE_BITS) { + xLow = xLow or (x shl i) + yLow = yLow or (y shl i) + zLow = zLow or (z shl i) + } else { + val at = i - Long.SIZE_BITS + xHigh = xHigh or (x shl at) + yHigh = yHigh or (y shl at) + zHigh = zHigh or (z shl at) + } + } + + return CyberspacePoint( + CyberspaceAxis(xHigh, xLow), + CyberspaceAxis(yHigh, yLow), + CyberspaceAxis(zHigh, zLow), + planeOf(hex)!!, + ) + } + + /** The coordinate a point writes as, the inverse of [decode]. */ + fun encode(point: CyberspacePoint): String { + val nibbles = IntArray(HEX_LENGTH) + if (point.plane == CyberspacePlane.IDEASPACE) nibbles[HEX_LENGTH - 1] = 1 + + for (i in 0 until AXIS_BITS) { + setBit(nibbles, 1 + i * 3, axisBit(point.z, i)) + setBit(nibbles, 2 + i * 3, axisBit(point.y, i)) + setBit(nibbles, 3 + i * 3, axisBit(point.x, i)) + } + + val out = StringBuilder(HEX_LENGTH) + for (n in nibbles) out.append(HEX[n]) + return out.toString() + } + + private const val HEX = "0123456789abcdef" + + /** Bit [i] of an axis, as 0 or 1. */ + private fun axisBit( + axis: CyberspaceAxis, + i: Int, + ): Long = + if (i < Long.SIZE_BITS) { + (axis.low ushr i) and 1L + } else { + (axis.high ushr (i - Long.SIZE_BITS)) and 1L + } + + /** + * Bit [n] of the 256-bit integer the hex spells, counting from the least + * significant. The string is big-endian, so bit `n` lives in the nibble `n / 4` + * places from the end, at position `n % 4` inside it. + */ + private fun bit( + hex: String, + n: Int, + ): Long = ((nibble(hex, HEX_LENGTH - 1 - n / 4) ushr (n % 4)) and 1).toLong() + + private fun setBit( + nibbles: IntArray, + n: Int, + value: Long, + ) { + if (value == 0L) return + val at = HEX_LENGTH - 1 - n / 4 + nibbles[at] = nibbles[at] or (1 shl (n % 4)) + } + + private fun nibble( + hex: String, + at: Int, + ): Int { + val c = hex[at] + return if (c in '0'..'9') c - '0' else c - 'a' + 10 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt new file mode 100644 index 0000000000..c4a19678d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt @@ -0,0 +1,228 @@ +/* + * 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 + +/** + * `CYBERSPACE_V2.md` §7.7 — the hider's clue as to where a bag is. + * + * A bag reveals nothing about its own location: its `d` tag is a hash of a + * hash (§7.2), and §7.7 is blunt about what that leaves. "Without more + * information, a bag is found only by intentionally deep scanning and/or + * wandering. With no additional information, any given bag is equally likely to + * be at any point in the full 2^256 coordinate space: an impossibly hardened + * secret." + * + * A hint is the way out, and it is **a search, not a direction**. The hider + * publishes an aligned box, as coarse as they like; a seeker derives the region + * key of every candidate region inside it and asks a relay for each `lookup_id`. + * What makes that possible from anywhere is the sentence that governs this + * whole feature: + * + * > The seeker's own position never enters this cost, because §7.1 makes + * > looking and walking equivalent: a region key can be computed for any + * > coordinate without traveling there. + * + * So the hint is also the **difficulty knob**. The price is + * `2^((Hx - h) + (Hy - h) + (Hz - h))` candidates — [gapBits] — and the hider + * sets it by how far above the bag's own height they pitch each axis. At the + * bottom of the range, three heights equal to `h` "name the region itself: the + * hint is then a destination the seeker can compute or walk to directly, not a + * search" ([isDestination]). At the top, a sector-only hint on a shallow bag is + * a gap of 75, which no one will ever sweep — that hint says where to travel, + * not where to search. + * + * A box need not be a cube. Equal heights make one; unequal ones make a slab or + * a column, and an axis at exactly the bag's height with two coarse ones turns + * the hunt into two dimensions. + */ +class CyberspaceHint( + /** The box's aligned base, and the plane it lies in. */ + val base: CyberspacePoint, + val heightX: Int, + val heightY: Int, + val heightZ: Int, +) { + /** + * The exponent of the candidate count: how many regions of the bag's height + * fit in this box, as a power of two. + * + * An exponent rather than a number because it runs to 255 — three axes left + * fully open — and nothing holds `2^255`. §7.7's own table is read in these + * terms: 12 is seconds, 24 is hours, 30 or more is "days to never". + */ + fun gapBits(bagHeight: Int): Int = (heightX - bagHeight) + (heightY - bagHeight) + (heightZ - bagHeight) + + /** The candidate count itself, when it fits, and null when the sweep is beyond counting. */ + fun candidates(bagHeight: Int): Long? { + val bits = gapBits(bagHeight) + return if (bits in 0..62) 1L shl bits else null + } + + /** + * How many distinct Cantor trees a sweep of this box has to build. + * + * Not the candidate count: §4.7 combines three independent per-axis roots, + * so a box needs one tree per distinct base *per axis* and then one pairing + * per candidate. That is `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` trees against + * `2^gap` combines — for an even box, a cube root of the candidates. The + * trees are the cheap half above about height 8, where a combine costs + * roughly ten times a root, so a budget that prices a sweep by its trees + * will under-quote badly. + */ + fun axisTrees(bagHeight: Int): Long? { + var total = 0L + for (height in intArrayOf(heightX, heightY, heightZ)) { + val bits = height - bagHeight + if (bits < 0 || bits > 40) return null + total += 1L shl bits + } + return total + } + + /** §7.7: three heights equal to the bag's name the region itself, so there is nothing to search. */ + fun isDestination(bagHeight: Int): Boolean = gapBits(bagHeight) == 0 + + /** + * Whether a region of [bagHeight] based at [regionBase] lies inside this box. + * + * §7.7 makes it two checks per axis, "because both the region and the box + * are aligned": the hint height is at least the bag's, and the two bases + * agree once both are shifted right by the hint height. + */ + fun contains( + regionBase: CyberspacePoint, + bagHeight: Int, + ): Boolean { + if (regionBase.plane != base.plane) return false + if (bagHeight > heightX || bagHeight > heightY || bagHeight > heightZ) return false + return regionBase.x.alignedBase(heightX) == base.x.alignedBase(heightX) && + regionBase.y.alignedBase(heightY) == base.y.alignedBase(heightY) && + regionBase.z.alignedBase(heightZ) == base.z.alignedBase(heightZ) + } + + /** + * The sector tags §10 requires a bag carrying this hint to publish. + * + * "It MUST carry the sector tag of each axis whose hint height is at most + * 30, computed from the box's base, and `S` when all three are fixed." An + * axis coarser than a sector has no determined sector and gets no tag — + * which is why the third golden vector, with `Hy = 40`, carries `X` and `Z` + * and neither `Y` nor `S`. + */ + fun sectorTags(): List> { + val out = mutableListOf>() + if (heightX <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("X", base.x.sector().toString())) + if (heightY <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("Y", base.y.sector().toString())) + if (heightZ <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("Z", base.z.sector().toString())) + if (out.size == 3) out.add(arrayOf("S", base.sector())) + return out + } + + /** The `hint` tag this box writes as. */ + fun toTag(): Array = arrayOf(TAG, CyberspaceCoordinate.encode(base), heightX.toString(), heightY.toString(), heightZ.toString()) + + override fun equals(other: Any?): Boolean = + other is CyberspaceHint && + other.base == base && + other.heightX == heightX && + other.heightY == heightY && + other.heightZ == heightZ + + override fun hashCode(): Int = ((base.hashCode() * 31 + heightX) * 31 + heightY) * 31 + heightZ + + companion object { + const val TAG = "hint" + + /** A hint height names an axis, so it runs to the axis's own width (§7.7). */ + const val MAX_HEIGHT = CyberspaceCoordinate.AXIS_BITS + + /** + * The hint a bag carries, or null when it carries none — **or carries a + * broken one**. + * + * §7.7 is explicit that those are the same answer: "A `hint` tag that + * breaks any rule above MUST be treated as absent, meaning the bag is + * read as if it carried no hint... A bad hint never invalidates the bag, + * because hints are advisory metadata about where to look; whether a bag + * is valid is decided by §7.2 and §7.6 alone." + * + * So every check below returns null rather than throwing, and a caller + * that gets null sweeps nothing and still reads the bag. + * + * @param bagHeight the `h` tag of §8.6, when the bag carries one. Its + * only job here is the last rule: "When the bag carries an `h` tag, + * each hint height MUST therefore be at least `h`; a box smaller than + * the region could not contain it." + */ + fun read( + tags: Array>, + bagHeight: Int? = null, + ): CyberspaceHint? { + val hints = tags.filter { it.isNotEmpty() && it[0] == TAG } + // "A bag MUST carry at most one `hint` tag." Two is a bag whose + // author cannot be read literally, and §7.7's own remedy for a rule + // broken is to read the bag as if it had no hint at all. + if (hints.size != 1) return null + return read(hints[0], bagHeight) + } + + /** One `hint` tag, or null when it breaks any rule of §7.7. */ + fun read( + tag: Array, + bagHeight: Int? = null, + ): CyberspaceHint? { + if (tag.size != 5 || tag[0] != TAG) return null + + val heightX = canonicalHeight(tag[2]) ?: return null + val heightY = canonicalHeight(tag[3]) ?: return null + val heightZ = canonicalHeight(tag[4]) ?: return null + + if (bagHeight != null && (heightX < bagHeight || heightY < bagHeight || heightZ < bagHeight)) return null + + val point = CyberspaceCoordinate.decode(tag[1]) ?: return null + // "It MUST be the aligned base: the low `H` bits of each axis MUST + // be zero, and an axis with `H = 85` MUST be `0`." Requiring the + // base is what lets two hiders who hint the same box publish the + // same tag, so readers can compare hints by equality. + if (point.x.alignedBase(heightX) != point.x) return null + if (point.y.alignedBase(heightY) != point.y) return null + if (point.z.alignedBase(heightZ) != point.z) return null + + return CyberspaceHint(point, heightX, heightY, heightZ) + } + + /** + * A height as §10 writes one: base ten, no sign, no leading zeros + * except `"0"` itself, and inside `[0, 85]`. + * + * The canonical form matters for the same reason the aligned base does: + * `"05"` and `"5"` are the same number and two different tags, and a + * reader that accepts both lets one box have two spellings. + */ + private fun canonicalHeight(text: String): Int? { + if (text.isEmpty()) return null + if (text.length > 1 && text[0] == '0') return null + for (c in text) if (c !in '0'..'9') return null + val value = text.toIntOrNull() ?: return null + return if (value in 0..MAX_HEIGHT) value else null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt new file mode 100644 index 0000000000..845845ad94 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt @@ -0,0 +1,125 @@ +/* + * 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 + +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.roundToLong + +/** + * How big one model unit actually is. + * + * DECK-0003 §1.6 says a `unit` of `u` means one model unit is `2^u` of the + * application's base unit, and leaves what that base unit *is* to the + * application. In Cyberspace it is the gibson, and `CYBERSPACE_V2.md` §9.2 and + * §9.7 fix the gibson at `2^-33` metres — about the width of a hydrogen atom. + * Those two facts together are the only thing standing between the integer in + * the payload and a size a reader can picture. + * + * They matter more than they look. Two objects with byte-identical geometry at + * `unit: 0` and `unit: 40` are a molecule and a mountain, and a client that + * prints "8 vertices · 12 faces" for both has told the reader nothing about + * the one field that separates them. The reference workshop puts this on + * screen as a scale ladder (`sno-core/scale.ts`); the numbers and the unit + * thresholds here are that file's, so the same object reads the same size in + * both. + * + * Nothing here is normative and nothing downstream depends on the exact + * wording: it is a length, rendered the way a person reads lengths. + */ +object CyberspaceScale { + /** The gibson in metres: `2^-33` (§9.2). */ + private const val GIBSON_METRES = 1.1641532182693481e-10 + + /** The largest `unit` DECK-0003 §1.8 allows, and the range these cover. */ + private const val MAX_UNIT = 84 + + private class Measure( + /** Used below this many metres. */ + val limit: Double, + val symbol: String, + val perMetre: Double, + ) + + // µ is the micro sign; written as an escape so the file stays + // unambiguous about which of the two lookalike code points it carries. + private val MEASURES = + listOf( + Measure(1e-9, "pm", 1e12), + Measure(1e-6, "nm", 1e9), + Measure(1e-3, "µm", 1e6), + Measure(1e-2, "mm", 1e3), + Measure(1e-1, "cm", 1e2), + Measure(1e3, "m", 1.0), + Measure(1e6, "km", 1e-3), + Measure(1e9, "Mm", 1e-6), + Measure(1.496e11, "AU", 1.0 / 1.496e11), + ) + + /** One model unit in metres, at this scale exponent. */ + fun unitInMetres(unit: Int): Double { + var metres = GIBSON_METRES + repeat(unit.coerceIn(0, MAX_UNIT)) { metres *= 2.0 } + return metres + } + + /** + * One model unit as a length a person reads: `"116 pm"`, `"1 m"`, `"2 AU"`. + * + * Three significant figures, in the largest measure the length fits inside, + * with nothing trailing that carries no information. + */ + fun describeUnit(unit: Int): String { + val metres = unitInMetres(unit) + val measure = MEASURES.firstOrNull { metres < it.limit } ?: MEASURES.last() + return significant(metres * measure.perMetre) + " " + measure.symbol + } + + /** + * A positive number to three significant figures, without a trailing zero + * or a trailing point. + * + * Built from a Long rather than a platform formatter, because there is no + * locale-independent one in common Kotlin and a size that renders as + * "1,91 µm" for half the world and "1.91 µm" for the other half is a + * number two readers cannot compare. + */ + private fun significant(value: Double): String { + if (value <= 0.0 || !value.isFinite()) return "0" + val exponent = floor(log10(value)).toInt() + + // Four digits or more: three of them carry the figure and the rest are + // zeros, so 1876 reads 1880 and 15060 reads 15100. + if (exponent >= 2) { + var step = 1L + repeat((exponent - 2).coerceAtMost(17)) { step *= 10L } + return ((value / step).roundToLong() * step).toString() + } + + val places = (2 - exponent).coerceAtMost(12) + var scale = 1L + repeat(places) { scale *= 10L } + val scaled = (value * scale).roundToLong() + val whole = scaled / scale + val fraction = (scaled % scale).toString().padStart(places, '0').trimEnd('0') + return if (fraction.isEmpty()) whole.toString() else "$whole.$fraction" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt new file mode 100644 index 0000000000..a690d70f1b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt @@ -0,0 +1,110 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** What deriving a region at one height produces (`CYBERSPACE_V2.md` §7.2). */ +class RegionKeyMaterial( + val height: Int, + /** §4.7's stable spatial region integer. */ + val regionN: UBigInt, + /** The 32 bytes a bag's payload is encrypted under. Not to be published. */ + val decryptionKey: ByteArray, + /** The `d` tag a bag carrying this region is addressed by. Safe to publish. */ + val lookupId: HexKey, +) + +/** + * `CYBERSPACE_V2.md` §7.2 — turning a place into a key. + * + * ``` + * region_bytes = int_to_bytes_be_min(region_n) + * location_decryption_key = sha256(region_bytes) + * lookup_id = sha256(location_decryption_key) + * ``` + * + * **Two layers, and the second one is the design.** The lookup id is published + * so that people can find the content; it is a hash *of* the key, so seeing it + * buys nothing without the region preimage. §7.2: "Seeing `lookup_id` does not + * allow deriving `location_decryption_key` without the region preimage. The + * lookup ID is safe to publish; the decryption key requires work." + * + * Note what is deliberately absent: the temporal axis that hop proofs use (§5) + * is not part of this. Location identifiers stay a stable function of space, so + * they do not change when somebody walks through. + */ +object RegionKey { + /** + * §7.4: the region integer for the aligned cube of [height] holding [point]. + * + * The three axes are independent all the way to the combine, which is why a + * §7.7 sweep of a box costs one tree per distinct base *per axis* rather + * than one per candidate region. + */ + fun regionN( + point: CyberspacePoint, + height: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): UBigInt { + val base = point.alignedBase(height) + val x = CantorTree.subtreeRoot(base.x.toUBigInt(), height, maxComputeHeight) + val y = CantorTree.subtreeRoot(base.y.toUBigInt(), height, maxComputeHeight) + val z = CantorTree.subtreeRoot(base.z.toUBigInt(), height, maxComputeHeight) + // §4.7: region_n = pi(pi(cantor_x, cantor_y), cantor_z). + return CantorTree.cantorPair(CantorTree.cantorPair(x, y), z) + } + + /** The key and the lookup id a region integer yields. */ + fun derive( + regionN: UBigInt, + height: Int = 0, + ): RegionKeyMaterial { + val key = sha256(regionN.toMinimalBytes()) + return RegionKeyMaterial(height, regionN, key, sha256(key).toHexKey()) + } + + /** Both halves at once, for the common case. */ + fun at( + point: CyberspacePoint, + height: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): RegionKeyMaterial = derive(regionN(point, height, maxComputeHeight), height) +} + +/** + * This axis as a number the Cantor tree can add to. + * + * The one conversion from the split representation into arbitrary precision, + * done at the boundary where arithmetic starts and nowhere earlier — the same + * rule the SNO lattice follows for the same reason. + */ +fun CyberspaceAxis.toUBigInt(): UBigInt { + val bytes = ByteArray(16) + for (i in 0..7) { + bytes[i] = (high ushr (56 - i * 8)).toByte() + bytes[8 + i] = (low ushr (56 - i * 8)).toByte() + } + return UBigInt.ofBytes(bytes) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt new file mode 100644 index 0000000000..a791fd602c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt @@ -0,0 +1,138 @@ +/* + * 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 + +import com.vitorpamplona.quartz.utils.bigint.UBigInt + +/** + * Sweeping the box a hint names (`CYBERSPACE_V2.md` §7.7). + * + * > A seeker who trusts a hint sweeps the box: for every candidate region of + * > height `h` inside it, derive the region key (§7.2), compute its + * > `lookup_id`, and check the relay for a bag with that `d` tag (one batched + * > query can carry many lookup ids). + * + * **The axes are swept independently up to the combine**, which is the whole + * reason this is affordable. §4.7 builds `region_n` from three per-axis Cantor + * roots, so a box needs one tree per distinct base *per axis* — + * `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` of them — and then one pairing per candidate, + * rather than a whole three-tree key each time. For an even box that is a cube + * root of the candidates in tree work. + * + * **It is a cold [Sequence] on purpose.** The caller holds the budget, because + * the budget is the only defence: a hint is a stranger's choice of difficulty, + * and a bag can carry a gap of 30 precisely to burn a day of a reader's battery. + * Nothing here starts work until something pulls, and a caller that stops + * pulling has stopped paying. Price the hint with [CyberspaceHint.gapBits] + * *before* the first pull. + * + * Nothing here talks to a relay. What comes out is `lookup_id`s and the keys + * that made them; asking for the bags, in batches, is the caller's job. + */ +object RegionSweep { + /** + * Every candidate region in [hint]'s box, as the key material that finds it. + * + * Ordered so that the axis roots are built once each and reused across the + * combines they take part in: X outermost, then Y, then Z. A caller pulling + * the first item therefore pays for `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` trees + * and one combine, and each item after that is one combine and two hashes. + * + * @param bagHeight the `h` of §8.6 — the height of the region the bag is + * keyed to, and the size of the candidates this enumerates. + */ + fun of( + hint: CyberspaceHint, + bagHeight: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): Sequence { + require(bagHeight >= 0) { "height must be >= 0" } + require(bagHeight <= hint.heightX && bagHeight <= hint.heightY && bagHeight <= hint.heightZ) { + "a box smaller than the region cannot contain it (§7.7)" + } + + return sequence { + val xs = axisRoots(hint.base.x, hint.heightX, bagHeight, maxComputeHeight) + val ys = axisRoots(hint.base.y, hint.heightY, bagHeight, maxComputeHeight) + val zs = axisRoots(hint.base.z, hint.heightZ, bagHeight, maxComputeHeight) + + for (x in xs) { + for (y in ys) { + val xy = CantorTree.cantorPair(x, y) + for (z in zs) { + yield(RegionKey.derive(CantorTree.cantorPair(xy, z), bagHeight)) + } + } + } + } + } + + /** + * The Cantor root of every aligned subtree of [bagHeight] inside this axis's + * span of the box. + * + * The bases step by `2^bagHeight` from the box's own base, which is where + * §7.7's "both the region and the box are aligned" pays off: the candidates + * on an axis are exactly the `2^(H - h)` aligned positions in it, with + * nothing to search between them. + */ + private fun axisRoots( + base: CyberspaceAxis, + boxHeight: Int, + bagHeight: Int, + maxComputeHeight: Int, + ): List { + val steps = boxHeight - bagHeight + require(steps <= MAX_AXIS_STEPS) { "an axis of 2^$steps candidates is past anything a client sweeps" } + + val count = 1L shl steps + val out = ArrayList(count.toInt()) + var position = base + for (i in 0 until count) { + out.add(CantorTree.subtreeRoot(position.toUBigInt(), bagHeight, maxComputeHeight)) + position = position.plusShifted(bagHeight) + } + return out + } + + /** + * How far a single axis may be swept before this refuses. + * + * Not a judgement about difficulty — §7.7 lets a hider set that — but about + * memory: the roots of one axis are held while the other two are walked, and + * a root at height 12 is forty kilobytes. A million of them is not a slow + * search, it is a dead process, and the caller's own budget should have + * stopped long before here. + */ + const val MAX_AXIS_STEPS = 20 +} + +/** This axis with `2^height` added: the next aligned position along it. */ +private fun CyberspaceAxis.plusShifted(height: Int): CyberspaceAxis { + if (height >= Long.SIZE_BITS) { + return CyberspaceAxis(high + (1L shl (height - Long.SIZE_BITS)), low) + } + val step = 1L shl height + val sum = low + step + // Unsigned overflow: the sum wrapped past 2^64, so carry into the high half. + val carried = (low.toULong() > sum.toULong()) + return CyberspaceAxis(if (carried) high + 1 else high, sum) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt new file mode 100644 index 0000000000..8429225d3a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -0,0 +1,122 @@ +/* + * 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.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip13Pow.hasPoWTag +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag + +/** + * `CYBERSPACE_V2.md` §8.10 — an avatar, kind 11333: the shape an identity is + * drawn as, carrying an SNO payload in its content or empty content for the + * default avatar. + * + * Replaceable, so relays keep the newest per `(pubkey, kind)` and an identity + * has exactly one avatar — which is why this extends [BaseReplaceableEvent] + * rather than plain `Event`, giving it the fixed-empty-`d` address the local + * store keys replaceables on. This kind **was** 33331 with a `d` fixed at + * `"avatar"` before the spec moved it here, and 33331 was then handed to the + * standalone objects of DECK-0003 §3.1 — so an old 33331 whose `d` is literally + * `avatar` is not a surprise, merely stale. + * + * An avatar is the one thing in cyberspace that lands on other people's screens + * whether they asked for it or not, so its size and detail are paid for in + * proof of work on the event that publishes it, and **a client MUST NOT draw an + * avatar that is not paid, or that carries content it cannot read** — it draws + * its default for that identity instead. [isPaid] is that gate. + */ +@Immutable +class SnoAvatarEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** True when this identity asked for the default avatar, which owes no work. */ + fun isDefaultAvatar(): Boolean = content.isBlank() + + fun sno(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) + + /** The `name` tag: the shape's name for humans. */ + fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + /** + * Whether this avatar has paid for the room it takes up, and why not when + * it has not (§8.10). + * + * Both conditions are required: the committed target must cover the work + * the payload owes, **and** the id must actually carry that many leading + * zero bits. Committing the target before mining is what stops a lucky id + * being claimed against a lower bar than it was mined for (NIP-13). + * + * Note that `Event.pow()` cannot stand in for this. It is + * `PoWRankEvaluator.compute(id, commitedPoW)`, which returns + * `min(actualRank, committed)` — so comparing it against the required work + * passes an event whose id falls short of its own commitment: work 16, + * committed 30 and an id carrying 20 gives 20, which clears 16 while + * failing the second condition outright. + */ + fun payment(fetchedPalette: SnoPalette? = null): SnoAvatarPayment { + val zeros = PoWRankEvaluator.calculatePowRankOf(id) + if (isDefaultAvatar()) { + return SnoAvatarPayment(true, 0, null, zeros, SnoAvatarPayment.Reason.DEFAULT_AVATAR) + } + + val payload = + sno(fetchedPalette).payloadOrNull() + ?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR) + + val required = SnoAvatarWork.required(payload) + // `parseCommitment` answers null both for "no nonce tag" and for "a + // nonce tag that committed to nothing". The verdict is the same either + // way, but the reason is what a caller reads, so tell them apart. + val committed = + tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } + ?: return SnoAvatarPayment( + ok = false, + required = required, + committed = null, + zeros = zeros, + reason = if (hasPoWTag()) SnoAvatarPayment.Reason.UNCOMMITTED_NONCE else SnoAvatarPayment.Reason.NO_NONCE, + payload = payload, + ) + + if (committed < required) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNDER_COMMITTED, payload) + } + if (zeros < committed) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNPAID, payload) + } + return SnoAvatarPayment(true, required, committed, zeros, SnoAvatarPayment.Reason.OK, payload) + } + + /** Whether a client may draw this avatar. See [payment] for why not. */ + fun isPaid(): Boolean = payment().ok + + companion object { + const val KIND = 11333 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt new file mode 100644 index 0000000000..323eb5cfb2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt @@ -0,0 +1,89 @@ +/* + * 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 + +/** + * Why an avatar may or may not be drawn (`CYBERSPACE_V2.md` §8.10). + * + * The fields and the [reason] vocabulary deliberately mirror + * `verify_avatar_work` in the reference implementations, so the two can be + * diffed directly rather than compared by eye. + */ +@Immutable +data class SnoAvatarPayment( + /** Whether a client may draw this avatar. */ + val ok: Boolean, + /** The leading zero bits this shape owes, or 0 when the shape is unreadable. */ + val required: Int, + /** The target the publisher committed to in its `nonce` tag, if it wrote one. */ + val committed: Int?, + /** The leading zero bits the event id actually carries. */ + val zeros: Int, + val reason: Reason, + /** + * The shape this verdict is about, when it could be read at all. + * + * Pricing an avatar means parsing it, so the parse is handed back rather + * than thrown away: a caller that draws a paid avatar would otherwise parse + * the same content a second time, and on Android that second parse lands on + * the composition thread. + */ + val payload: SnoPayload? = null, +) { + enum class Reason( + val code: String, + ) { + OK("ok"), + + /** + * Empty content, which §8.10 defines as the default avatar: it owes no + * work and there is nothing to draw but the client's own default. + * + * The reference implementations answer `not-an-avatar` here, because + * their check begins by parsing the content and empty is not JSON. The + * two agree on what happens — the default gets drawn — and differ only + * in what they call it. + */ + DEFAULT_AVATAR("default-avatar"), + + /** Not kind 11333, or content that cannot be read as a payload. */ + NOT_AN_AVATAR("not-an-avatar"), + + /** No `nonce` tag, so nothing was committed to before mining (NIP-13). */ + NO_NONCE("no-nonce"), + + /** + * A `nonce` tag that names no target. NIP-13 allows the bare two-element + * form, which mines without committing — and §8.10 needs the commitment, + * so this is unpaid for the same reason [NO_NONCE] is, by a different + * route worth telling apart when reading a verdict. + */ + UNCOMMITTED_NONCE("uncommitted-nonce"), + + /** The committed target does not cover the work the shape owes. */ + UNDER_COMMITTED("under-committed"), + + /** The id does not carry the zeros its own commitment promised. */ + UNPAID("unpaid"), + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt new file mode 100644 index 0000000000..5c902d6264 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt @@ -0,0 +1,86 @@ +/* + * 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.math.ceil +import kotlin.math.ln +import kotlin.math.max + +/** + * The proof of work an avatar owes for its size and its detail + * (`CYBERSPACE_V2.md` §8.10, normative). + * + * ```python + * AVATAR_FLOOR_BITS = 16 # every avatar + * AVATAR_SIZE_BITS = 2 # per doubling of reach + * AVATAR_DETAIL_BITS = 3 # per doubling of detail beyond the free thirty-two + * AVATAR_DETAIL_FREE = 32 + * + * reach = max(1.0, max(abs(v + t / 120) for every vertex coordinate) * 2 ** payload.unit) + * detail = max(AVATAR_DETAIL_FREE, len(payload.vertices) + len(payload.faces)) + * return ceil(AVATAR_FLOOR_BITS + AVATAR_SIZE_BITS * log2(reach) + AVATAR_DETAIL_BITS * log2(detail / AVATAR_DETAIL_FREE)) + * ``` + * + * Reach is the term that matters to other people, since a large avatar is the + * one that gets in everyone's way: two bits per doubling makes a shape twice as + * far across cost four times the work, so a modest shape of a few gibsons costs + * minutes on a phone while a sector-sized one is out of reach of any hash power. + * Reach is measured from the build origin rather than the shape's own centre, + * so a shape is priced as its builder placed it. + */ +object SnoAvatarWork { + const val FLOOR_BITS = 16 + const val SIZE_BITS = 2 + const val DETAIL_BITS = 3 + const val DETAIL_FREE = 32 + + /** The leading zero bits [payload] must have been mined to, as an avatar. */ + fun required(payload: SnoPayload): Int { + // Widened before negating: the parser bounds positions long before this + // runs, but a magnitude that is its own negative would price an avatar + // at nothing, and this is the one number that decides what gets drawn. + var farthestTicks = 0L + for (tick in payload.positions) { + val magnitude = if (tick < 0) -tick.toLong() else tick.toLong() + if (magnitude > farthestTicks) farthestTicks = magnitude + } + + // In the application's base unit at true scale: one model unit is 2^unit + // of it, and a tick is one 120th of a model unit. Never below one, which + // is what `max(1.0, ...)` does before the logarithm. + val reach = max(1.0, (farthestTicks.toDouble() / SnoPayload.TICKS_PER_UNIT) * pow2(payload.unit)) + val detail = max(DETAIL_FREE, payload.vertexCount + payload.faceCount) + + val bits = FLOOR_BITS + SIZE_BITS * log2(reach) + DETAIL_BITS * log2(detail.toDouble() / DETAIL_FREE) + return ceil(bits).toInt() + } + + /** `2^exponent` for the 0..84 range `unit` allows, beyond what an Int holds. */ + private fun pow2(exponent: Int): Double { + var value = 1.0 + repeat(exponent) { value *= 2.0 } + return value + } + + private fun log2(value: Double): Double = ln(value) / LN_2 + + private val LN_2 = ln(2.0) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt new file mode 100644 index 0000000000..3a2e983312 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt @@ -0,0 +1,85 @@ +/* + * 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 + +/** + * `cyberspace-neon-256`, the palette an object's colour indices name when it + * carries no `palette` of its own (DECK-0003 Appendix C, normative). + * + * The layout is arithmetic rather than a list to memorise: + * - `0..191` 24 hues of 8 steps; hue `h` step `s` is `h * 8 + s`, step 0 darkest. + * - `192..223` 32 steels, black to white, faintly cyan. + * - `224..255` 32 signatures: the client's instrument colours, the six sRGB gamut + * corners, six neon staples and six deep grounds. + * + * Transcribed from `decks/sno-palette.json` in the cyberspace repository + * (`"built": "2026-09-16"`), which is generated by `decks/sno-palette.mjs`. + * Generated here rather than typed: [SnoBuiltInPaletteTest] pins the documented + * anchors so a bad paste cannot ship silently. + */ +object SnoBuiltInPalette { + /** The registered name of this palette, the only one DECK-0003 §1.3a defines. */ + const val NAME = "cyberspace-neon-256" + + private const val HEX = + "003632004d49006562007e7d00979900b2b800cdd700e8f9" + // 0 + "003538004c5100636d007c8a0095a900afca00c9ed6fdfff" + // 8 + "00343f004a5a0061780079980092ba00abdf34c4ff93d8ff" + // 16 + "003346004865005e860075ab008dd200a4fd6ebcffa7d3ff" + // 24 + "00305200447600589e006ccc017fff5a9bff8bb5ffb5ceff" + // 32 + "00266d002cac1902ff374fff5a73ff7e92ffa1aeffc3c9ff" + // 40 + "2200883700b75000e76837ff8064ff9b86ffb6a5ffd0c4ff" + // 48 + "3500794f00a36b00cd8b00f8a24bffb875ffcd9affe1bcff" + // 56 + "4200695f008d7f00b1a200d5c600f8dd53ffed84fff9afff" + // 64 + "4b00586b00768e0094b200b1d900cdff1ae7ff7de2ffb1e6" + // 72 + "52004674005f980077be008ee600a4ff45b4ff87c0ffb5d2" + // 80 + "5700357a00489f005ac6006bee007bff538dff8ca6ffb7c2" + // 88 + "5a00247e0031a3003dcb0047f4004eff5b6aff908effb9b4" + // 96 + "5c0010800014a60013ce0006f22400ff613eff9375ffbba5" + // 104 + "561200752200943300b44600d45a00f47000ff9652ffbd93" + // 112 + "4b1f00683100854300a25700c06d00de8300fc9b00ffc077" + // 120 + "4426005e3800794c00946100b07800cc8f00e7a800ffc333" + // 128 + "3e2a00563d006e5300876900a08100b99900d2b300ebce00" + // 136 + "372d004d42006358007970008f8800a5a300b9be00cdda00" + // 144 + "2f3000424600555e0067770077910087ad0094ca009fe800" + // 152 + "243400314b003d6400457f00499b0045b9002cd90000f837" + // 160 + "073900005108006a1f008436009f4e00ba6700d68200f39e" + // 168 + "00381e00503000684400825b009c7300b78d00d3a900efc6" + // 176 + "00372a004e3f00665600806e009a8900b4a500d0c200ece1" + // 184 + "010101020203050606090b0c0f121315191a1b2021222728" + // 192 + "282e302f3638373e403e4648454e504d5658555e605d6769" + // 200 + "666f716e787a77808380898c899294939b9d9ca4a6a6adaf" + // 208 + "b0b6b8babfc1c4c9caced2d3d8dcdce3e5e6eeefeff8f8f8" + // 216 + "000000ffffff00e5ffff3b6bf7931a52e39fc8f5ff6f8ea0" + // 224 + "1d354705070dff00ff00ff0000ffffffff00ff00000000ff" + // 232 + "39ff14ff6ec77df9ffb026fffffb00ff330000ff9fff007f" + // 240 + "4d4dffffd3000a0f1a12182a1a0f240f1f1c2410161c1c0f" // 248 + + /** 256 opaque ARGB colours, index-aligned with the sheet in Appendix C. */ + val COLORS: IntArray = + IntArray(256) { i -> + val at = i * 6 + val r = HEX.substring(at, at + 2).toInt(16) + val g = HEX.substring(at + 2, at + 4).toInt(16) + val b = HEX.substring(at + 4, at + 6).toInt(16) + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt new file mode 100644 index 0000000000..937bad07dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt @@ -0,0 +1,44 @@ +/* + * 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 + +/** + * Which of the three drawings of the same vertex list a client should produce + * (DECK-0003 §1.5). Mode is a property of the object, not of the viewer. + */ +enum class SnoMode( + val code: String, +) { + /** The triangles in `faces`, colours interpolated across each face. */ + SOLID("solid"), + + /** The vertices, each at its own colour. */ + POINTS("points"), + + /** The edges of the faces, each drawn once; with no faces, one polyline through the vertices in order. */ + LINES("lines"), + + ; + + companion object { + fun parseOrNull(code: String?): SnoMode? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt new file mode 100644 index 0000000000..ad0ec2c1bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt @@ -0,0 +1,92 @@ +/* + * 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.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * DECK-0003 §3.1 — a standalone Simple Nostr Object, kind 33331. + * + * `content` is the payload of §1 serialized as JSON; [sno] reads it. The `d` + * tag is the object's identifier, chosen by its author and stable across + * edits, and an optional `name` tag duplicates the payload's name so a relay + * query can filter on it without parsing the content. + * + * 33331 falls in NIP-01's addressable range, so relays keep the newest event + * per `(pubkey, kind, d)` and an author edits an object in place. That is the + * right class for a thing someone iterates on in a modeling tool, and the deck + * states its cost plainly: reactions, zaps and comments reference an event id, + * and an address resolves to whatever its author last published, so a payment + * made against an object can end up pointing at content that changed after it. + * + * There is deliberately no regular twin of this kind. An object that must stay + * as it was found travels as a `kind 3330` bag item instead (§3.2), which this + * client does not read — a current bag item is encrypted to a cyberspace + * region key (`CYBERSPACE_V2.md` §7.6) and is not fetchable on its own. + * + * @see DECK-0003 + */ +@Immutable +class SnoObjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * The object, or the rule it broke. A client MUST NOT render a payload that + * fails §1.9 and SHOULD say why rather than failing silently (§3.1), which + * is why this hands back the failure instead of a null. + */ + fun sno(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) + + fun snoOrNull(fetchedPalette: SnoPalette? = null): SnoPayload? = sno(fetchedPalette).payloadOrNull() + + /** The `name` tag, which duplicates the payload's name for relay-side filtering. */ + fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + companion object { + const val KIND = 33331 + const val ALT = "A 3D object" + + fun build( + dTag: String, + payloadJson: String, + name: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, payloadJson, createdAt) { + dTag(dTag) + name?.let { add(arrayOf("name", it)) } + alt(name?.let { "$ALT: $it" } ?: ALT) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt new file mode 100644 index 0000000000..8903d629c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt @@ -0,0 +1,70 @@ +/* + * 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 + +/** + * The 2 to 256 colours an object's indices name (DECK-0003 §1.3a), as opaque ARGB. + */ +@Immutable +class SnoPalette( + val colors: IntArray, +) { + val size: Int get() = colors.size + + operator fun get(index: Int): Int = colors[index] + + companion object { + const val MIN_ENTRIES = 2 + const val MAX_ENTRIES = 256 + + val BUILT_IN = SnoPalette(SnoBuiltInPalette.COLORS) + } +} + +/** + * What an object's `palette` field said, kept after parsing so a caller can + * decide whether to go looking for a referenced palette event. + * + * The rule that makes a reference safe is that it is never load-bearing + * (§1.3b): an unresolved reference reads as the built-in, so the worst case is + * an object drawn in the wrong colours and never one that cannot be drawn. + */ +@Immutable +sealed class SnoPaletteRef { + /** No `palette` field, or the registered name of the built-in. */ + data object BuiltIn : SnoPaletteRef() + + /** + * An `nevent1…` or `naddr1…` naming a palette published as its own event. + * Pinned to the event it names: a reader MUST NOT follow the `e` chain + * forward to a newer version (§1.3b). + */ + data class Event( + val bech32: String, + ) : SnoPaletteRef() + + /** A palette carried in the object itself. */ + data class Inline( + val palette: SnoPalette, + ) : SnoPaletteRef() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt new file mode 100644 index 0000000000..41db05e9eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt @@ -0,0 +1,120 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull + +/** + * Reads a palette out of an event an object's `palette` reference named + * (DECK-0003 §1.3b). + * + * The kind is deliberately not constrained: this format does not define a + * palette kind and does not want one. `kind 3367`, the colour-moment + * convention, is what carries them today, and a reader that accepts the shape + * below will read whatever convention wins without the deck being revised. + * + * Two shapes, in order, and the first that succeeds wins: + * + * 1. **The `c` tags**, in the order they appear, if there are 2 to 256 of them + * and every value is a well-formed `#rrggbb`. Their order is the palette's + * order. This is the form every palette on the network actually uses, and + * it is a form relays index: `{"#c": ["#FF0000"]}` finds every palette + * containing pure red, with no new index and no new kind. + * 2. Otherwise **`content`**, if it parses as a JSON array of 2 to 256 entries + * that are each `[r, g, b]` integers or a `"#rrggbb"` string. A legacy form, + * accepted only because an earlier draft described it; nothing should write + * it now. + * + * Anything else is not a palette event, which counts as a failed fetch, which + * means the built-in — never a refusal to draw the object that named it. + */ +object SnoPaletteEventReader { + fun read(event: Event): SnoPalette? = fromTags(event) ?: fromContent(event.content) + + private fun fromTags(event: Event): SnoPalette? { + val values = event.tags.mapNotNull { if (it.size > 1 && it[0] == "c") it[1] else null } + if (values.size < SnoPalette.MIN_ENTRIES || values.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(values.size) + for (i in values.indices) { + colors[i] = parseHexColor(values[i]) ?: return null + } + return SnoPalette(colors) + } + + private fun fromContent(content: String): SnoPalette? { + val entries = + try { + KotlinSerializationMapper.json.parseToJsonElement(content) as? JsonArray + } catch (_: Exception) { + null + } ?: return null + + if (entries.size < SnoPalette.MIN_ENTRIES || entries.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(entries.size) + for (i in 0 until entries.size) { + colors[i] = + when (val entry = entries[i]) { + is JsonArray -> { + if (entry.size != 3) return null + val r = channel(entry[0]) ?: return null + val g = channel(entry[1]) ?: return null + val b = channel(entry[2]) ?: return null + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + is JsonPrimitive -> { + if (!entry.isString) return null + parseHexColor(entry.content) ?: return null + } + else -> return null + } + } + return SnoPalette(colors) + } + + private fun channel(element: JsonElement): Int? { + val primitive = element as? JsonPrimitive ?: return null + if (primitive.isString) return null + val value = primitive.intOrNull ?: return null + return if (value in 0..255) value else null + } + + /** `#rrggbb`, and nothing else: no shorthand, no alpha, no bare hex. */ + private fun parseHexColor(value: String): Int? { + if (value.length != 7 || value[0] != '#') return null + var rgb = 0 + for (i in 1..6) { + val digit = + when (val c = value[i]) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> return null + } + rgb = (rgb shl 4) or digit + } + return (0xFF shl 24) or rgb + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt new file mode 100644 index 0000000000..865fba4816 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt @@ -0,0 +1,455 @@ +/* + * 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.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlin.math.roundToInt + +/** What [SnoParser] made of a payload. */ +@Immutable +sealed class SnoResult { + @Immutable + data class Valid( + val payload: SnoPayload, + ) : SnoResult() + + /** + * The payload was refused. [rule] names the numbered rule of DECK-0003 §1.9 + * that failed, so a client can say why rather than failing silently, which + * §3.1 asks it to do. + */ + @Immutable + data class Invalid( + val rule: String, + val reason: String, + ) : SnoResult() + + fun payloadOrNull(): SnoPayload? = (this as? Valid)?.payload +} + +/** + * Reads a Simple Nostr Object out of an event's content (DECK-0003 §1). + * + * §1.9 is enforced in full and in its own order: a payload that fails any rule + * is refused whole, because "a partially valid object is not rendered + * partially: a face index pointing past the end of the vertex list is not a + * defect that degrades gracefully". Counts are checked against the arrays + * themselves and buffers are sized from those counts, never from a number the + * payload supplies — the format has no length fields for exactly this reason + * (§6). + * + * Written against the deck rather than ported, and checked against both + * reference implementations: `decks/sno-reference.py` in the cyberspace + * repository, which carries a rejection case per rule, and `sno-core`, the MIT + * TypeScript library ONOSENDAI and the snocrash workshop both read with. Where + * the two disagree, see [readLiteralTriple]. + */ +object SnoParser { + /** + * @param fetchedPalette the palette an [SnoPaletteRef.Event] resolved to, if + * one has been fetched since. Absent, a reference reads as the built-in, + * which §1.3b requires: a reader MUST render without waiting for anything. + */ + fun parse( + content: String, + fetchedPalette: SnoPalette? = null, + ): SnoResult { + val root = + try { + KotlinSerializationMapper.json.parseToJsonElement(content) as? JsonObject + } catch (_: Exception) { + null + } ?: return SnoResult.Invalid("json", "content is not a JSON object") + + return parse(root, fetchedPalette) + } + + fun parse( + root: JsonObject, + fetchedPalette: SnoPalette? = null, + ): SnoResult { + // Rule 1. `v` is 1 or 2. A `type` field, if present, is ignored and never + // rejected on (§1.1a): the field never said anything the kind did not, + // and rejecting on noise would refuse every object already published. + val version = root["v"].asIntOrNull() + if (version != 1 && version != 2) return SnoResult.Invalid("1", "v is not 1 or 2") + + // Rule 2. The three required arrays, and vertices parallel to colors. + val vertices = root["vertices"] as? JsonArray ?: return SnoResult.Invalid("2", "vertices is not an array") + val colors = root["colors"] as? JsonArray ?: return SnoResult.Invalid("2", "colors is not an array") + val faces = root["faces"] as? JsonArray ?: return SnoResult.Invalid("2", "faces is not an array") + if (vertices.size != colors.size) return SnoResult.Invalid("2", "vertices and colors differ in length") + + // Rule 3. The limits, enforced before anything is allocated from them (§6). + if (vertices.size > SnoPayload.MAX_VERTICES) return SnoResult.Invalid("3", "more than ${SnoPayload.MAX_VERTICES} vertices") + if (faces.size > SnoPayload.MAX_FACES) return SnoResult.Invalid("3", "more than ${SnoPayload.MAX_FACES} faces") + + // Rule 4. + val mode = SnoMode.parseOrNull(root["mode"].asStringOrNull()) ?: return SnoResult.Invalid("4", "mode is not solid, points or lines") + + // Rule 5. + val unit = root["unit"].asIntOrNull() ?: return SnoResult.Invalid("5", "unit is not an integer") + if (unit < 0 || unit > SnoPayload.MAX_UNIT) return SnoResult.Invalid("5", "unit is outside 0..${SnoPayload.MAX_UNIT}") + + // Rule 7, before rule 6, because a position needs both halves to exist + // before it can be assembled. Absent ticks means every position is whole. + val ticks = expandTicks(root["ticks"], vertices.size) ?: return SnoResult.Invalid("7", "ticks do not expand to one remainder per vertex") + + // Rule 6. §1.8 puts the position bound on publishers — "a publisher MUST + // NOT write a vertex further than 64 model units from the origin" — and + // lets a reader either reject such a payload or "repair it by growing + // the extent". Both references repair: `sno-core`'s `fromPayload` does + // not check a position at all and lets `neededExtent` grow past its own + // `MAX_EXTENT`, and `sno-reference.py` does not check either. So this + // repairs too, under rule 9 below, and the only bound left here is the + // one the lattice itself imposes (see MAX_TICKS_FROM_ORIGIN). + val positions = IntArray(vertices.size * 3) + for (i in 0 until vertices.size) { + val triple = vertices[i] as? JsonArray ?: return SnoResult.Invalid("6", "vertex $i is not an array") + if (triple.size != 3) return SnoResult.Invalid("6", "vertex $i is not three integers") + for (axis in 0..2) { + // Read and multiplied as a Long, because the product is what has + // to fit: a whole of Int.MIN_VALUE would otherwise overflow `* + // 120` to 0 and parse as a silently rewritten coordinate, and + // `abs` would not catch it either, being its own negative there. + val whole = triple[axis].asLongOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers") + val total = whole * SnoPayload.TICKS_PER_UNIT + ticks[i * 3 + axis] + if (total > SnoPayload.MAX_TICKS_FROM_ORIGIN || total < -SnoPayload.MAX_TICKS_FROM_ORIGIN) { + return SnoResult.Invalid("bound", "vertex $i lies past what the lattice can hold exactly") + } + // §2: a `v: 1` payload has +Z away from the viewer, so its Z is + // negated on read, which renders the object exactly as its author + // built it. A `v: 2` payload is read as written. + positions[i * 3 + axis] = (if (axis == 2 && version == 1) -total else total).toInt() + } + } + + // Rule 8. + val faceIndices = IntArray(faces.size * 3) + for (i in 0 until faces.size) { + val triple = faces[i] as? JsonArray ?: return SnoResult.Invalid("8", "face $i is not an array") + if (triple.size != 3) return SnoResult.Invalid("8", "face $i is not three indices") + val a = triple[0].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + val b = triple[1].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + val c = triple[2].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + if (a < 0 || b < 0 || c < 0 || a >= vertices.size || b >= vertices.size || c >= vertices.size) { + return SnoResult.Invalid("8", "face $i points at a vertex that does not exist") + } + if (a == b || b == c || a == c) return SnoResult.Invalid("8", "face $i does not have three distinct vertices") + faceIndices[i * 3] = a + faceIndices[i * 3 + 1] = b + faceIndices[i * 3 + 2] = c + } + + // Rule 8a. An unresolved reference counts as the built-in and is never a + // reason to reject (§1.3b). + val paletteRef = readPaletteRef(root["palette"]) ?: return SnoResult.Invalid("8a", "palette is not a known name, a well-formed nevent/naddr, or 2..256 RGB entries") + val palette = + when (paletteRef) { + is SnoPaletteRef.Inline -> paletteRef.palette + is SnoPaletteRef.Event -> fetchedPalette ?: SnoPalette.BUILT_IN + SnoPaletteRef.BuiltIn -> SnoPalette.BUILT_IN + } + + // Rule 8b. + val vertexColors = IntArray(colors.size) + for (i in 0 until colors.size) { + vertexColors[i] = readColor(colors[i], version, palette) ?: return SnoResult.Invalid("8b", "colors[$i] is not a valid colour for a v$version payload") + } + + // Rule 8c. + val faceColorsField = root["facecolors"] + val faceColors = + if (faceColorsField == null) { + null + } else { + val expanded = expandFaceColors(faceColorsField, faces.size, palette.size) ?: return SnoResult.Invalid("8c", "facecolors do not expand to one valid index per face") + IntArray(expanded.size) { palette[expanded[it]] } + } + + // Rule 10. + val upField = root["up"] + if (upField != null && upField.asBooleanOrNull() == null) return SnoResult.Invalid("10", "up is not a boolean") + val up = upField?.asBooleanOrNull() == true + val spinField = root["spin"] + // Validated whether or not `up` is present, because a payload carrying a + // nonsense bearing is malformed even where the bearing is unused (§1.7). + if (spinField != null) { + val spin = spinField.asIntOrNull() + if (spin == null || spin < 0 || spin > 359) return SnoResult.Invalid("10", "spin is not an integer in 0..359") + } + + // Rule 9. `extent` is repaired rather than validated: out of range becomes + // the default, then it grows to contain the data. The data wins and the + // hint is corrected, so an object is never refused for disagreeing with + // its own extent (§1.8) — and, since rule 6 no longer turns away a + // vertex past 64 units, this is also where such a vertex is repaired. + // The grown extent may therefore exceed MAX_EXTENT, exactly as the + // reference's `neededExtent` does; MAX_EXTENT bounds what a payload may + // *declare*, not what its geometry may need. + val declared = root["extent"].asIntOrNull() + val repaired = if (declared != null && declared >= SnoPayload.MIN_EXTENT && declared <= SnoPayload.MAX_EXTENT) declared else SnoPayload.DEFAULT_EXTENT + val extent = grownExtent(repaired, positions) + + return SnoResult.Valid( + SnoPayload( + version = version, + name = root["name"].asStringOrNull()?.take(SnoPayload.MAX_NAME) ?: "", + unit = unit, + extent = extent, + mode = mode, + positions = positions, + colors = vertexColors, + faces = faceIndices, + faceColors = faceColors, + paletteRef = paletteRef, + up = up, + // §1.7: a reader MUST ignore the value of `spin` when `up` is not true. + spin = if (up) spinField?.asIntOrNull() ?: 0 else 0, + ), + ) + } + + /** + * The grid half-width that holds every vertex, in model units (§1.8). + * Rounded away from zero, so a vertex at 8 units and one tick needs 9. + */ + private fun grownExtent( + declared: Int, + positions: IntArray, + ): Int { + var needed = declared + for (tick in positions) { + val magnitude = if (tick < 0) -tick.toLong() else tick.toLong() + val units = ((magnitude + SnoPayload.TICKS_PER_UNIT - 1) / SnoPayload.TICKS_PER_UNIT).toInt() + if (units > needed) needed = units + } + return needed + } + + /** + * The sub-unit part of every position, run-length decoded (§1.2). + * + * An entry is either a triple, standing for one vertex, or a negative + * integer `-N`, standing for N consecutive vertices whose remainder is + * `[0, 0, 0]`. Zero and positive integers are not valid entries, and the + * entries must expand to exactly one remainder per vertex. Absent means + * every position is whole. + */ + private fun expandTicks( + field: JsonElement?, + vertexCount: Int, + ): IntArray? { + val out = IntArray(vertexCount * 3) + if (field == null) return out + + val entries = field as? JsonArray ?: return null + var written = 0 + for (entry in entries) { + when (entry) { + is JsonArray -> { + if (entry.size != 3) return null + if (written >= vertexCount) return null + for (axis in 0..2) { + val v = entry[axis].asIntOrNull() ?: return null + if (v < 0 || v >= SnoPayload.TICKS_PER_UNIT) return null + out[written * 3 + axis] = v + } + written++ + } + is JsonPrimitive -> { + val run = entry.asIntOrNull() ?: return null + // Bounded before it is negated. `-Int.MIN_VALUE` is itself + // negative, which would drive `written` below zero and index + // out of the buffer on the next triple; and a run longer than + // the vertex list is invalid anyway, so one test does both. + if (run >= 0 || run < -vertexCount) return null + val count = -run + if (written + count > vertexCount) return null + written += count + } + else -> return null + } + } + return if (written == vertexCount) out else null + } + + /** + * One palette index per face, run-length decoded (§1.4a). + * + * An entry is either a palette index, read exactly as §1.3 reads one, or a + * negative integer `-N` standing for N further faces of the index before + * it. The first entry must be an index, since a run has nothing to repeat + * before one, and the sign is what separates the two kinds of entry — + * which works because an index is never negative. + */ + private fun expandFaceColors( + field: JsonElement, + faceCount: Int, + paletteSize: Int, + ): IntArray? { + val entries = field as? JsonArray ?: return null + val out = IntArray(faceCount) + var written = 0 + var previous = -1 + for (entry in entries) { + val value = (entry as? JsonPrimitive).asIntOrNull() ?: return null + if (value < 0) { + if (previous < 0) return null + // Bounded before it is negated, as in expandTicks: `-Int.MIN_VALUE` + // is negative, so an unguarded run would be swallowed rather than + // refused. A run longer than the face list is invalid regardless. + if (value < -faceCount) return null + val count = -value + if (written + count > faceCount) return null + repeat(count) { out[written++] = previous } + } else { + if (value >= paletteSize) return null + if (written >= faceCount) return null + out[written++] = value + previous = value + } + } + return if (written == faceCount) out else null + } + + /** §1.3a, with rule 8a's shapes. Null means the payload is refused. */ + private fun readPaletteRef(field: JsonElement?): SnoPaletteRef? { + if (field == null) return SnoPaletteRef.BuiltIn + + val name = field.asStringOrNull() + if (name != null) { + if (name == SnoBuiltInPalette.NAME) return SnoPaletteRef.BuiltIn + val entity = Nip19Parser.uriToRoute(name)?.entity + return if (entity is NEvent || entity is NAddress) SnoPaletteRef.Event(name) else null + } + + val entries = field as? JsonArray ?: return null + if (entries.size < SnoPalette.MIN_ENTRIES || entries.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(entries.size) + for (i in 0 until entries.size) { + val rgb = entries[i] as? JsonArray ?: return null + if (rgb.size != 3) return null + val r = rgb[0].asIntOrNull() ?: return null + val g = rgb[1].asIntOrNull() ?: return null + val b = rgb[2].asIntOrNull() ?: return null + if (r !in 0..255 || g !in 0..255 || b !in 0..255) return null + colors[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + return SnoPaletteRef.Inline(SnoPalette(colors)) + } + + private fun readColor( + field: JsonElement, + version: Int, + palette: SnoPalette, + ): Int? { + if (field is JsonArray) return readLiteralTriple(field) + + // An index is version 2's form, and version 1 never had one. + if (version == 1) return null + val index = field.asIntOrNull() ?: return null + if (index < 0 || index >= palette.size) return null + return palette[index] + } + + /** + * A literal `[r, g, b]` of numbers from 0 to 1, clamped on read and taken + * exactly as written — never snapped to the nearest palette entry, because + * snapping on read would change objects nobody asked to change (§1.3). + * + * This is version 1's colour, and it is **also accepted in version 2**, + * which is the one place this reader is knowingly more forgiving than + * `sno-reference.py`. Version 2 arrived as two changes that did not ship + * together: the wire turned right-handed first, and colour became a palette + * index two releases later. Objects written in between declare `v: 2`, + * carry triples, and are correct in every other respect including their + * frame. §5 permits a reader to be generous with them, `sno-core` was + * changed to accept them because refusing orphaned real work, and three of + * the seven objects on the network are of exactly this shape — so a strict + * reader shows an error for nearly half of what exists. + * + * The two forms cannot be confused, because one is an array and the other + * an integer. Note that the frame still follows the declared version: one + * of these is in the v2 frame and flipping it as though it were v1 would + * mirror the object. + */ + private fun readLiteralTriple(triple: JsonArray): Int? { + if (triple.size != 3) return null + val r = channel(triple[0]) ?: return null + val g = channel(triple[1]) ?: return null + val b = channel(triple[2]) ?: return null + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun channel(field: JsonElement): Int? { + val raw = (field as? JsonPrimitive)?.let { if (it.isString) null else it.doubleOrNull } ?: return null + val clamped = + if (raw < 0.0) { + 0.0 + } else if (raw > 1.0) { + 1.0 + } else { + raw + } + return (clamped * 255.0).roundToInt() + } + + /** + * A JSON integer as a Long, or null when the token is not one — including a + * whole number too large for a Long, which is not a coordinate anybody can + * hold and is rejected the same way a string would be. + */ + private fun JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.longOrNull + } + + private fun JsonElement?.asIntOrNull(): Int? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.intOrNull + } + + private fun JsonElement?.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } + + private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.booleanOrNull + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt new file mode 100644 index 0000000000..c1341e707c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt @@ -0,0 +1,119 @@ +/* + * 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 + +/** + * One Simple Nostr Object: a small triangle mesh on an integer lattice + * (DECK-0003 §1). A single connected budget of vertices, colours and faces; + * not a scene and not a hierarchy. + * + * Positions are exact. [positions] holds **total ticks** — whole units times + * [TICKS_PER_UNIT] plus the sub-unit remainder — as a single integer per axis, + * which is the same exact value the wire's `vertices`/`ticks` pair carries and + * is what §1.2 requires welding, deduplication, equality, sorting and hashing + * to run on. One 120th is not representable in binary floating point, so a + * reader that compares derived floats has given away the one guarantee the + * lattice makes. Convert to a float at the rasteriser and nowhere earlier. + * + * Colours arrive resolved: [colors] and [faceColors] are opaque ARGB, already + * looked up through whichever palette [paletteRef] named. A palette reference + * that has not been fetched resolves against the built-in, so the payload is + * always drawable; when the referenced event does arrive, re-parse the content + * with it rather than patching this object. + */ +@Immutable +class SnoPayload( + /** Format version, 1 or 2 (§2). Z has already been negated for a `v: 1` payload. */ + val version: Int, + /** A name for humans, truncated to [MAX_NAME] characters. */ + val name: String, + /** Scale exponent: one model unit is `2^unit` of the application's base unit (§1.6). */ + val unit: Int, + /** + * Grid half-width in model units, repaired and grown to contain the data + * (§1.8). A declared extent is only honoured inside `1..`[MAX_EXTENT]; the + * grown one has no ceiling, because the geometry is what it is. + */ + val extent: Int, + val mode: SnoMode, + /** Three total-tick coordinates per vertex: X, Y, Z. */ + val positions: IntArray, + /** One opaque ARGB colour per vertex, parallel to the vertices. */ + val colors: IntArray, + /** Three vertex indices per face. */ + val faces: IntArray, + /** One opaque ARGB colour per face, or null when every face interpolates its vertices (§1.4a). */ + val faceColors: IntArray?, + val paletteRef: SnoPaletteRef, + /** True when the object stands on the Earth's surface where it is placed (§1.7). */ + val up: Boolean, + /** With [up], the compass bearing the object's `+Z` faces, 0 to 359. */ + val spin: Int, +) { + val vertexCount: Int get() = colors.size + + val faceCount: Int get() = faces.size / 3 + + /** The total-tick coordinate of [vertex] on [axis] (0 = X, 1 = Y, 2 = Z). */ + fun tickAt( + vertex: Int, + axis: Int, + ): Int = positions[vertex * 3 + axis] + + companion object { + /** The lattice spacing: a tick is one 120th of a model unit (§1.2). */ + const val TICKS_PER_UNIT = 120 + + const val MAX_VERTICES = 512 + const val MAX_FACES = 1024 + const val MIN_EXTENT = 1 + const val MAX_EXTENT = 64 + const val DEFAULT_EXTENT = 8 + const val MAX_UNIT = 84 + const val MAX_NAME = 64 + + /** + * The farthest a vertex may lie from the origin on any axis, in ticks. + * + * This is a representation limit and not a rule of the format. §1.8 + * puts the position bound on publishers — "a publisher MUST NOT write a + * vertex further than `64` model units (`7680` ticks) from the origin + * on any axis" — and offers a reader two ways to answer one that does: + * "A reader MAY reject such a payload and MAY instead repair it by + * growing the extent." Both references repair. `sno-core`'s + * `fromPayload` never looks at a position's magnitude and lets + * `neededExtent` grow the extent past its own `MAX_EXTENT`, and + * `sno-reference.py` does not check either. So Amethyst repairs, and an + * object that reaches past the grid is drawn rather than refused; what + * is left here is only the point at which the lattice stops being able + * to say where a vertex is. + * + * That point is `Int.MAX_VALUE` ticks, about 17.9 million model units, + * because a position is a total tick count in an Int — the exact + * integer §1.2 requires every weld, comparison and hash to run on. Past + * it there is no coordinate to repair, only one that would wrap, so + * that is a rejection rather than a judgement about size. + */ + const val MAX_TICKS_FROM_ORIGIN = Int.MAX_VALUE.toLong() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt new file mode 100644 index 0000000000..482965d146 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -0,0 +1,114 @@ +/* + * 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.cyberspace.CyberspaceCoordinate +import com.vitorpamplona.quartz.cyberspace.CyberspacePlane +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 Cantor + * trees of §4, the derivation of §7.2 and a sweep of the hinted box of §7.7. + * + * Not for want of somewhere to stand — §7.7 is explicit that "the seeker's own + * position never enters this cost, because §7.1 makes looking and walking + * equivalent", so a client with no avatar can open a hinted bag, and + * [com.vitorpamplona.quartz.cyberspace.RegionSweep] does. What decides whether + * it is worth trying is the work: a key is three `O(2^h)` folds of BigInts that + * double in width every level, which the spec measures at 816 ms per key at + * height 16 on a desktop core and which grows about 2.2x per height above that, + * and a hint's box multiplies that by up to 2^75. So a bag is opened only when + * its own hint prices the search into reach, and a reader who does not get + * there sees base64 and nothing else — §7.6 is explicit that a failed + * decryption "MUST NOT be treated as an error in the bag". A shard can also + * arrive with no bag at all: 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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * True when this shard carries no payload of its own. + * + * Almost every 3330 that reaches a client this way is one: a shard's + * geometry travels inside its bag's ciphertext, and what is left on a relay + * is the item without it. §7.6 is explicit that not being able to read a + * bag "MUST NOT be treated as an error in the bag", so this is a thing to + * be quiet about rather than a malformed payload to complain of. + */ + fun isSealed(): Boolean = content.isBlank() + + 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) + + /** + * The plane the claimed coordinate lies in, or null when there is no `C` + * tag or it is not a coordinate. + * + * One bit of §2.2, and the only part of a coordinate a client with no world + * can turn into something a reader understands: whether the shard was + * hidden at a place on Earth or at one that has no physical counterpart. + * See [CyberspaceCoordinate] for why it stops there. + */ + fun plane(): CyberspacePlane? = coordinate()?.let { CyberspaceCoordinate.planeOf(it) } + + companion object { + const val KIND = 3330 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt index 3271d75549..168b586808 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt @@ -64,6 +64,12 @@ expect object Ed25519 { * @return 32-byte public key */ fun publicFromPrivate(privateKey: ByteArray): ByteArray + + /** + * Rebuild a key pair from a 32-byte seed (RFC 8032 §5.1.5), for keys stored as seeds only. + * @return pair of (privateKey: 64 bytes seed+public, publicKey: 32 bytes) + */ + fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair } data class Ed25519KeyPair( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..4a6c73ae6c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -196,6 +196,8 @@ class MlsGroup private constructor( */ internal fun groupContextExtensionsSnapshot(): List = groupContext.extensions.toList() + private fun allMembersSupportExtension(extensionType: Int): Boolean = (0 until tree.leafCount).all { i -> tree.getLeaf(i)?.let { extensionType in it.capabilities.extensions } ?: true } + /** * Encode the current ratchet tree the same way it's serialized into * the GroupInfo's `ratchet_tree` extension on a Welcome — a freshly- @@ -509,6 +511,8 @@ class MlsGroup private constructor( leafExtensions: List = emptyList(), capabilities: Capabilities = marmotLeafCapabilities(), keyPackageExtensions: List = emptyList(), + /** The leaf's lifetime. Null uses the Marmot window (84 days, backdated an hour for clock skew). */ + lifetime: Lifetime? = null, ): KeyPackageBundle { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() @@ -523,6 +527,7 @@ class MlsGroup private constructor( signingKey = sigKp.privateKey, capabilities = capabilities, leafExtensions = leafExtensions, + lifetime = lifetime, ) val unsigned = @@ -1116,8 +1121,15 @@ class MlsGroup private constructor( * * The signature is computed with `SignWithLabel(., "FramedContentTBS", * FramedContentTBS)` using the member's signature private key. + * + * [authenticatedData] is sent in the clear as the message's + * `authenticated_data` (RFC 9420 §6.3.2). It is bound to the message by the + * AEAD and the signature, and returned by [decrypt]. */ - fun encrypt(plaintext: ByteArray): ByteArray { + fun encrypt( + plaintext: ByteArray, + authenticatedData: ByteArray = ByteArray(0), + ): ByteArray { // Trim sentKeys if it grows too large if (sentKeys.size > MAX_SENT_KEYS) { val sortedKeys = sentKeys.keys.sorted() @@ -1146,7 +1158,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, senderLeafIndex = myLeafIndex, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, applicationData = plaintext, groupContext = groupContext, ), @@ -1162,7 +1174,7 @@ class MlsGroup private constructor( val pmcPlaintext = pmcWriter.toByteArray() // Build PrivateContentAAD (RFC 9420 §6.3.2) - val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0)) + val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, authenticatedData) val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext) // Build sender data plaintext: leaf_index || generation || reuse_guard @@ -1200,7 +1212,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, contentType = ContentType.APPLICATION, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, encryptedSenderData = encryptedSenderData, ciphertext = ciphertext, ) @@ -1442,6 +1454,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = applicationData, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1475,6 +1488,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = commitBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1562,6 +1576,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = proposalBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } } @@ -2898,9 +2913,11 @@ class MlsGroup private constructor( } is Proposal.GroupContextExtensions -> { - // Validate extension types are supported (RFC 9420 Section 12.1.7) + // RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. Types this + // implementation knows are accepted as before; any other type is accepted when every member's leaf + // advertises it in capabilities.extensions. for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES) { + require(ext.extensionType in KNOWN_EXTENSION_TYPES || allMembersSupportExtension(ext.extensionType)) { "Unsupported extension type: ${ext.extensionType}" } } @@ -3624,7 +3641,9 @@ class MlsGroup private constructor( * the MIP-era set; a current-profile group passes * [buildCurrentProfileRequiredCapabilitiesExtension]. */ - requiredCapabilities: Extension = buildMarmotRequiredCapabilitiesExtension(), + requiredCapabilities: Extension? = buildMarmotRequiredCapabilitiesExtension(), + /** The group's MLS `group_id`. Null picks 32 random bytes. */ + groupId: ByteArray? = null, ): MlsGroup { val sigKp = signingKey?.let { key -> @@ -3633,7 +3652,7 @@ class MlsGroup private constructor( } ?: Ed25519.generateKeyPair() val encKp = X25519.generateKeyPair() - val groupId = MlsCryptoProvider.randomBytes(32) + val groupId = groupId ?: MlsCryptoProvider.randomBytes(32) val leafNode = buildLeafNode( @@ -3654,7 +3673,7 @@ class MlsGroup private constructor( // bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so // new peers who join later can see the group name without first // decrypting a pre-membership bootstrap commit — see MIP-03). - val baseExtensions = listOf(requiredCapabilities) + val baseExtensions = listOfNotNull(requiredCapabilities) val groupContext = GroupContext( groupId = groupId, @@ -4254,6 +4273,7 @@ class MlsGroup private constructor( parentHash: ByteArray? = null, capabilities: Capabilities = marmotLeafCapabilities(), leafExtensions: List = emptyList(), + lifetime: Lifetime? = null, ): LeafNode { val unsigned = LeafNode( @@ -4265,7 +4285,9 @@ class MlsGroup private constructor( capabilities = capabilities, leafNodeSource = source, lifetime = - if (source == LeafNodeSource.KEY_PACKAGE) { + if (source == LeafNodeSource.KEY_PACKAGE && lifetime != null) { + lifetime + } else if (source == LeafNodeSource.KEY_PACKAGE) { // A real, bounded window. `Lifetime(0, Long.MAX_VALUE)` // used to go here, which any receiver enforcing // `foundation/key-packages.md` rejects outright: the @@ -4563,19 +4585,23 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, + /** The message's `authenticated_data` (RFC 9420 §6.3.2), verified by the AEAD. */ + val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DecryptedMessage) return false return senderLeafIndex == other.senderLeafIndex && content.contentEquals(other.content) && - epoch == other.epoch + epoch == other.epoch && + authenticatedData.contentEquals(other.authenticatedData) } override fun hashCode(): Int { var result = senderLeafIndex result = 31 * result + content.contentHashCode() result = 31 * result + epoch.hashCode() + result = 31 * result + authenticatedData.contentHashCode() return result } } 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 c60222860a..d597a994b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,10 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +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 @@ -649,6 +653,10 @@ class EventFactory { FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + CyberspaceBagEvent.KIND -> CyberspaceBagEvent(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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt new file mode 100644 index 0000000000..ad3684671e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt @@ -0,0 +1,276 @@ +/* + * 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.utils.bigint + +/** + * A non-negative integer of arbitrary size, in portable Kotlin: the engine + * behind [UBigInt] everywhere Java's is not available. + * + * On JVM and Android [UBigInt] wraps `java.math.BigInteger`, whose + * `multiplyToLen` is a HotSpot intrinsic and which measured 3 to 10 times + * faster than this on the operands a Cantor tree reaches. That speed matters — + * `CYBERSPACE_V2.md` §7's whole feasibility argument is a number — but Apple + * and Linux have nothing to wrap, so this exists and has to be exactly as + * correct, because what it computes becomes a decryption key (§7.2). A limb + * handled differently here is an object that opens on a desktop and not on a + * phone. + * + * That risk is discharged by measurement rather than by argument: + * `PortableUBigIntDifferentialTest` runs every operation against + * `java.math.BigInteger` over random inputs at the sizes the tree reaches, and + * `CantorTreeBenchmark` folds a whole subtree both ways and compares the roots. + * + * **Unsigned by construction.** Nothing here ever goes negative: the Cantor + * pairing of two non-negative numbers is non-negative, and [subtract] is used + * only inside Karatsuba where the result is known to be. That removes sign + * handling, two's complement, and the whole class of bug where a leading zero + * byte does or does not appear. + * + * Limbs are 32 bits each, little-endian — `limbs[0]` is the least significant — + * and normalised so the top limb is never zero. Zero is the empty array. + */ +internal class PortableUBigInt internal constructor( + internal val limbs: IntArray, +) : Comparable { + /** How many bits this number occupies; 0 for zero. */ + val bitLength: Int + get() { + if (limbs.isEmpty()) return 0 + val top = limbs[limbs.size - 1] + return limbs.size * Int.SIZE_BITS - leadingZeros(top) + } + + val isZero: Boolean get() = limbs.isEmpty() + + operator fun plus(other: PortableUBigInt): PortableUBigInt { + if (isZero) return other + if (other.isZero) return this + val long = if (limbs.size >= other.limbs.size) limbs else other.limbs + val short = if (limbs.size >= other.limbs.size) other.limbs else limbs + val out = IntArray(long.size + 1) + var carry = 0L + for (i in long.indices) { + val sum = (long[i].toLong() and MASK) + (if (i < short.size) short[i].toLong() and MASK else 0L) + carry + out[i] = sum.toInt() + carry = sum ushr Int.SIZE_BITS + } + out[long.size] = carry.toInt() + return normalised(out) + } + + /** + * `this - other`, which the caller guarantees is not negative. + * + * Only Karatsuba calls this, on `(a0 + a1)(b0 + b1) - z0 - z2`, which is a + * cross term and cannot go below zero. A borrow off the end would mean the + * multiplication itself was wrong, so it is an error rather than a wrap. + */ + internal fun subtract(other: PortableUBigInt): PortableUBigInt { + if (other.isZero) return this + val out = IntArray(limbs.size) + var borrow = 0L + for (i in limbs.indices) { + val diff = (limbs[i].toLong() and MASK) - (if (i < other.limbs.size) other.limbs[i].toLong() and MASK else 0L) - borrow + out[i] = diff.toInt() + borrow = if (diff < 0) 1L else 0L + } + check(borrow == 0L) { "unsigned subtract went below zero" } + return normalised(out) + } + + operator fun times(other: PortableUBigInt): PortableUBigInt { + if (isZero || other.isZero) return ZERO + if (limbs.size < KARATSUBA_LIMBS || other.limbs.size < KARATSUBA_LIMBS) { + return schoolbook(limbs, other.limbs) + } + return karatsuba(this, other) + } + + /** This number with its low [bits] bits dropped. */ + fun shiftRight(bits: Int): PortableUBigInt { + require(bits >= 0) { "shift must not be negative" } + if (bits == 0 || isZero) return this + val wholeLimbs = bits / Int.SIZE_BITS + if (wholeLimbs >= limbs.size) return ZERO + val withinLimb = bits % Int.SIZE_BITS + val out = IntArray(limbs.size - wholeLimbs) + if (withinLimb == 0) { + limbs.copyInto(out, 0, wholeLimbs, limbs.size) + } else { + for (i in out.indices) { + val low = limbs[i + wholeLimbs] ushr withinLimb + val high = if (i + wholeLimbs + 1 < limbs.size) limbs[i + wholeLimbs + 1] shl (Int.SIZE_BITS - withinLimb) else 0 + out[i] = low or high + } + } + return normalised(out) + } + + /** + * The `int_to_bytes_be_min` of the reference implementation: big-endian, no + * leading zero byte, and a single zero byte for zero. + * + * This exact shape is what §7.2 hashes, so a spare leading byte — which is + * what `java.math.BigInteger.toByteArray()` adds whenever the top bit is + * set — would silently produce a different key for one number in two. + */ + fun toMinimalBytes(): ByteArray { + if (isZero) return byteArrayOf(0) + val bytes = (bitLength + 7) / 8 + val out = ByteArray(bytes) + for (i in 0 until bytes) { + val limb = limbs[i / 4] + out[bytes - 1 - i] = (limb ushr ((i % 4) * 8)).toByte() + } + return out + } + + override fun compareTo(other: PortableUBigInt): Int { + if (limbs.size != other.limbs.size) return if (limbs.size < other.limbs.size) -1 else 1 + for (i in limbs.indices.reversed()) { + val a = limbs[i].toLong() and MASK + val b = other.limbs[i].toLong() and MASK + if (a != b) return if (a < b) -1 else 1 + } + return 0 + } + + override fun equals(other: Any?): Boolean = other is PortableUBigInt && limbs.contentEquals(other.limbs) + + override fun hashCode(): Int = limbs.contentHashCode() + + override fun toString(): String = "PortableUBigInt($bitLength bits)" + + companion object { + private const val MASK = 0xFFFFFFFFL + + /** + * Below this many limbs on either side, schoolbook wins: Karatsuba's + * three sub-products and their adds cost more than the `n * m` limb + * multiplications they save. The exact crossover is not sensitive — + * anything in the tens works — and this one is measured, not guessed. + */ + private const val KARATSUBA_LIMBS = 40 + + val ZERO = PortableUBigInt(IntArray(0)) + val ONE = PortableUBigInt(intArrayOf(1)) + + fun of(value: Long): PortableUBigInt { + require(value >= 0) { "negative values have no place on this lattice" } + if (value == 0L) return ZERO + val high = (value ushr Int.SIZE_BITS).toInt() + return if (high == 0) PortableUBigInt(intArrayOf(value.toInt())) else PortableUBigInt(intArrayOf(value.toInt(), high)) + } + + /** An unsigned big-endian magnitude, the inverse of [toMinimalBytes]. */ + fun ofBytes(bytes: ByteArray): PortableUBigInt { + if (bytes.isEmpty()) return ZERO + val out = IntArray((bytes.size + 3) / 4) + for (i in bytes.indices) { + val fromEnd = bytes.size - 1 - i + out[i / 4] = out[i / 4] or ((bytes[fromEnd].toInt() and 0xFF) shl ((i % 4) * 8)) + } + return normalised(out) + } + + private fun normalised(limbs: IntArray): PortableUBigInt { + var size = limbs.size + while (size > 0 && limbs[size - 1] == 0) size-- + if (size == 0) return ZERO + return PortableUBigInt(if (size == limbs.size) limbs else limbs.copyOf(size)) + } + + private fun leadingZeros(value: Int): Int { + if (value == 0) return Int.SIZE_BITS + var count = 0 + var v = value + while (v > 0) { + v = v shl 1 + count++ + } + return count + } + + private fun schoolbook( + a: IntArray, + b: IntArray, + ): PortableUBigInt { + val out = IntArray(a.size + b.size) + for (i in a.indices) { + val ai = a[i].toLong() and MASK + if (ai == 0L) continue + var carry = 0L + for (j in b.indices) { + val at = i + j + val product = ai * (b[j].toLong() and MASK) + (out[at].toLong() and MASK) + carry + out[at] = product.toInt() + carry = product ushr Int.SIZE_BITS + } + var at = i + b.size + while (carry != 0L) { + val sum = (out[at].toLong() and MASK) + carry + out[at] = sum.toInt() + carry = sum ushr Int.SIZE_BITS + at++ + } + } + return normalised(out) + } + + /** + * `a * b` in three half-width products instead of four. + * + * Splitting both at `half` limbs, `a = a1·B + a0` and `b = b1·B + b0`, + * the product is `z2·B² + z1·B + z0` where `z2 = a1·b1`, `z0 = a0·b0` + * and the cross term `z1 = (a0 + a1)(b0 + b1) − z2 − z0`, which is one + * multiplication rather than two. That turns the exponent from 2 to + * about 1.585, and on the numbers a Cantor tree reaches — megabytes by + * height 16 — it is the difference between a key and a coffee break. + */ + private fun karatsuba( + a: PortableUBigInt, + b: PortableUBigInt, + ): PortableUBigInt { + val half = maxOf(a.limbs.size, b.limbs.size) / 2 + val a0 = a.low(half) + val a1 = a.high(half) + val b0 = b.low(half) + val b1 = b.high(half) + + val z0 = a0 * b0 + val z2 = a1 * b1 + val z1 = ((a0 + a1) * (b0 + b1)).subtract(z2).subtract(z0) + + return z2.shiftLeftLimbs(half * 2) + z1.shiftLeftLimbs(half) + z0 + } + } + + private fun low(limbCount: Int): PortableUBigInt = if (limbs.size <= limbCount) this else normalised(limbs.copyOfRange(0, limbCount)) + + private fun high(limbCount: Int): PortableUBigInt = if (limbs.size <= limbCount) ZERO else normalised(limbs.copyOfRange(limbCount, limbs.size)) + + private fun shiftLeftLimbs(limbCount: Int): PortableUBigInt { + if (isZero || limbCount == 0) return this + val out = IntArray(limbs.size + limbCount) + limbs.copyInto(out, limbCount) + return PortableUBigInt(out) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt new file mode 100644 index 0000000000..a81cbe97a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt @@ -0,0 +1,84 @@ +/* + * 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.utils.bigint + +/** + * A non-negative integer of arbitrary size, with only the operations Cantor + * pairing needs (`CYBERSPACE_V2.md` §4.6). + * + * Two implementations, for one reason: speed on the platforms people use, and + * an implementation at all on the ones they might. JVM and Android wrap + * `java.math.BigInteger`, whose `multiplyToLen` is a HotSpot intrinsic and + * which measured 3 to 10 times faster than portable Kotlin on the operands a + * Cantor tree reaches — and §7's feasibility is a number, so that factor is the + * difference between a search a reader will wait for and one they will not. + * Apple and Linux have nothing to wrap and get [PortableUBigInt]. + * + * Two implementations of a **consensus value** would normally be a bad trade: + * §7.2 turns this into a decryption key, so a carry handled differently on one + * platform is an object that opens on a desktop and not on a phone. What makes + * it safe is that the disagreement is testable, and tested — every operation + * against `java.math.BigInteger` on random inputs at the sizes that matter, and + * a whole subtree folded both ways with the roots compared. + * + * Unsigned throughout: nothing on this lattice is negative, so there is no sign + * to carry and no two's complement to get wrong. + */ +expect class UBigInt : Comparable { + /** How many bits this number occupies; 0 for zero. */ + val bitLength: Int + + val isZero: Boolean + + operator fun plus(other: UBigInt): UBigInt + + operator fun times(other: UBigInt): UBigInt + + /** This number with its low [bits] bits dropped. */ + fun shiftRight(bits: Int): UBigInt + + /** + * The reference's `int_to_bytes_be_min`: big-endian, no leading zero byte, + * and a single zero byte for zero. + * + * This exact shape is what §7.2 hashes. `java.math.BigInteger.toByteArray()` + * is two's complement and prepends `0x00` whenever the top bit is set — + * half of all numbers — so the JVM actual strips it, and a test pins that + * it did. + */ + fun toMinimalBytes(): ByteArray + + override fun compareTo(other: UBigInt): Int + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int + + companion object { + val ZERO: UBigInt + val ONE: UBigInt + + fun of(value: Long): UBigInt + + /** An unsigned big-endian magnitude, the inverse of [toMinimalBytes]. */ + fun ofBytes(bytes: ByteArray): UBigInt + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt new file mode 100644 index 0000000000..01bf939118 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt @@ -0,0 +1,214 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * §7.6's bag, opened with a key the reference implementation would derive and a + * payload the reference implementation encrypted. + * + * The two vectors below were produced by `cryptography`'s AES-256-GCM, the same + * primitive `cyberspace-cli`'s `encrypt_with_location_key` calls, under the + * london height-4 key that `RegionKeyTest` already pins against the reference. + * So a pass here means the whole chain holds end to end: their coordinate, their + * Cantor roots, their key derivation, their cipher, our reader. + * + * The item list is deliberately mixed — a signed note, an unsigned item, a + * forged copy of the note with one byte of content changed, and a signed shard — + * because §7.6's rule about a bad item is the one most likely to be implemented + * as "reject the bag". + */ +class CyberspaceBagEventTest { + /** The key of the london vector at height 4, from `RegionKeyTest`. */ + private val key = Hex.decode("314ade123f63601cc69a0addf455ea5c2d84ea848b84285aee5a00941e316ab4") + + private val itemsPayload = "AAECAwQFBgcICQoLikF2BmXHqvoLgsTTZr3X9aZ0QQMXvpkcOPEtBi/voCzJmyQSBWKQ+F72zKwph7HgxRVzBB9h/0AXE3vvfYr9GO1hA39fx+PMxXMpQIIm8mgbAwjXaNHptMO5JoP4BaIK6+/CKlkisTnWlgKZv3c4NaANGgC2A6ul4F/ArroMbfhnt/dgRsQ8+LYmGShg88UbIV5tgSNIyanpd3ORcJy3hEsBmMBIfLetGh42TTbG7K36A7VBmTDpoymJrhqbrGaLbiSYfqgDIeQ6aKo74qBy7vg+YDFdP895RI/VvuiXYFCKk+DaDiWUG4exYsMNR1XZr6ECpn9F9WCFh3o1DATzYmYvDCkwM66euqcwk7D0wURBZTEmHJlfyAliCxWtIK5AdZucelvWO1v1geyoQ/3SaZ6cgfm1qQn3aEuK5cVCEEUiaaBd6zdn1JiLfVkHo/0xCkr4VQNo9dnLNh46iY/LO1IIsgjity5qYYaYMpFgUs9oFUeJ1v1tBMT0cogZ0rbFD2TmJ8MWPYT5BilG/gzjAWLYc8vEXJH4hbxGVVuitnQ5szAdJhD27TpRgEplcSmUBInJwkQQ2iY3F+baZp4M6QBNT3VKDbfL9XejqZECslH6RZ1zpla1+zGRvIT6724IlnuVgnpTy++EUJS8O6oFc6KNFfVryMwASOzd9geCCSQHf7ljhxESf98G36hyFGahMQyRfAorkWA99ulHERtNzUw6Su/WkvTUIcMBeAlbyuB5vluBddq2Z4IcLEbSn9gB2Ey5iRSCA1VifzhGSkZAgUZ7UB16FZdLFRmgjpUIdJBxy3XsL7fnl/TCQLoSat2IO157J7oe4pJ3qfrJX1ihqs0BTzGTdWNIR5slzQZIZiBelOyCxBVDslDCpn/av53CRZUpIkEXTkBQbQ+RNz1RGKVQLt/B01BfpFpAv1RoiWR7/B31/Hu2gHJv++/ey0Q6ByY7T6c/5SvAlDA55rZZwau27SG31cDu92ZLosqmHA+AvYirTjqY16UtAJJHaFMxYyb2TToUkHFK2DbMHIQuYacY3tITbnOpknTUZJgQfI0hFkSBfMZpRlod/1MT+tyzTeA9UCzf/HrEn0piOBvtdPs+eul/g5P7eAKStsY1ztZN37BeQT1LGplelYi/1Bu9Ok8uie3/uDMoixhnD3/taNo6UwCkX9eoS+Dt/y36+IFSEbQ38T5IDoiA9ZiLcESUZdaplWyOdPgPQhSrqFvMBR/iKvpW2gs/8rvgbNKBK08CUBFsTM9xhClBWG2MxHDWBcYzGue/1U687FF7VXU5T2HUOYLw53OZMdg6vw/z5JTssE3y5stjJZ3LTpPgIMinn4p3n5HG18prMfuF2ML9FuuvOvactdwxomW2LHsnM6XWKxEGcw+4/nZGti580uVYZZG5kqXptmyJ7GXXSM+vUEUSeC/wVDEEMPjcKLf/UlJKLrLNPLD92dRYut3xwCj3/v5ZuVlGjd/ZTUqGTLZJTXPMs73KcT+nORsz1JSi+nrmvgVjzuppY67dRP7mH5dB/YxueAT8VFPRr+bf+uii28MAXQk7p3sJ2RNNO/MhNG7eI+rEMhzrEJI0+34VZ1HuUwEk7DxTS7BmOgQQaLXZHMX7nbtGKRIVa9oxQc93ay/OTU66fR1W+UlXT6ED7aUzcpNKr2CaxWnhMpNJ2ELSBPpZIbON3Bjq0NKmI0s8V1cPX5HdzA40QzKOfWcuO5uE6apATYcuYmmQcauNJbZ3iX0QdQV8R4d0YFZh9ycFI6ybwD3yRVGwpM9vJZ1yOJE0gB2L4b9EEazSQB0Np9k67N8lNSuMKfp5TOJKr+Wk39Z+hLW6YMUFGM4e4DU9wqSZP6dlfeQkxHDasPqB2UzGYIb+zpIzfMJUd5zlz+VCdWg=" + + private val opaquePayload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA==" + + private fun bag( + payload: String, + vararg extra: Array, + ): CyberspaceBagEvent = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"), + arrayOf("version", "2"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + ) + extra, + content = "", + sig = "0".repeat(128), + ) + + @Test + fun aBagTheReferenceEncryptedOpensWithTheKeyTheReferenceDerives() { + val contents = bag(itemsPayload).open(key) + assertNotNull(contents, "the reference's own cipher, under the reference's own key") + assertTrue(contents is CyberspaceBagContents.Items, "a JSON array of events is the item shape (§7.6)") + } + + @Test + fun aForgedItemCostsItselfAndNothingElse() { + // §7.6: "a reader MUST drop an item that fails either check, and only + // that item, because one corrupt or forged item says nothing about the + // others." + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + assertEquals(1, contents.dropped, "the forged copy of the note") + assertEquals(3, contents.items.size, "the signed note, the unsigned item and the signed shard") + } + + @Test + fun anUnsignedItemIsKeptAndItsAuthorshipIsNotClaimed() { + // §7.6: "An item without a `sig` is allowed... its `pubkey` is then a + // claim, and readers MUST NOT present it as verified." + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + val unsigned = contents.items.single { it.event.sig.isBlank() } + assertFalse(unsigned.verified) + assertEquals("f".repeat(64), unsigned.event.pubKey, "the claim is carried, not endorsed") + // And the signed ones are verified, by the same field. + assertEquals(2, contents.items.count { it.verified }) + } + + @Test + fun anItemMayNameItsOwnPointInsideTheRegion() { + // §7.6: an item MAY carry a `C` tag, "which lets a client render it at a + // point rather than somewhere in the region". + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + val placed = contents.items.single { it.coordinate() != null } + assertEquals("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", placed.coordinate()) + assertTrue(contents.items.count { it.coordinate() == null } > 0, "and an item without one is located no more precisely than the region") + } + + @Test + fun aKindTheReaderDoesNotKnowIsStillAnItem() { + // §7.6: "A reader that does not understand an item's `kind` skips it and + // renders the rest" — skipping is the renderer's job, so the reader + // hands over everything that verified. + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + assertTrue(contents.items.any { it.event.kind == 3330 }, "the shard came through as an item") + assertTrue(contents.items.any { it.event.kind == 1 }) + } + + @Test + fun aPlaintextThatIsNotAListIsOpaque() { + // §7.6's other shape: "anything that is not a list of items, such as a + // text note or a file". + val contents = bag(opaquePayload).open(key) + assertTrue(contents is CyberspaceBagContents.Opaque) + assertEquals("just some words, not a list", contents.bytes.decodeToString()) + } + + @Test + fun theWrongKeyIsSilenceRatherThanAnError() { + // §7.6: "An attempt with the wrong key fails at the GCM tag check and + // reveals nothing about the plaintext. A failed decryption therefore + // means only that the reader does not hold this region's key; it MUST + // NOT be treated as an error in the bag." + val wrong = ByteArray(32) { 7 } + assertNull(bag(itemsPayload).open(wrong)) + // And a key of the wrong size is not a key at all. + assertNull(bag(itemsPayload).open(ByteArray(16))) + } + + @Test + fun aVersionTheReaderDoesNotKnowIsIgnored() { + // §8.6: "`version` names the rules of §7.6. A reader MUST ignore a bag + // whose version it does not know." + val future = + CyberspaceBagEvent( + "a".repeat(64), + "b".repeat(64), + 1700000000, + arrayOf( + arrayOf("d", "abc"), + arrayOf("version", "3"), + arrayOf("encrypted", "aes-256-gcm", itemsPayload), + ), + "", + "0".repeat(128), + ) + assertFalse(future.isKnownVersion()) + assertNull(future.open(key), "a bag whose rules are unknown is not opened with this reader's") + } + + @Test + fun aCipherThisDoesNotImplementIsNotAMalformedBag() { + // Only the lookup and the key are normative across the protocol; §7.6's + // cipher is the convention the CLI and ONOSENDAI share. A bag naming + // another one is somebody else's format. + val other = + CyberspaceBagEvent( + "a".repeat(64), + "b".repeat(64), + 1700000000, + arrayOf(arrayOf("version", "2"), arrayOf("encrypted", "chacha20-poly1305", itemsPayload)), + "", + "0".repeat(128), + ) + assertNull(other.payload()) + assertNull(other.open(key)) + } + + @Test + fun theTagsThatMakeABagFindableAreRead() { + val hinted = + bag( + itemsPayload, + arrayOf("hint", "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", "11", "11", "11"), + ) + assertEquals("a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916", hinted.lookupId()) + assertEquals(4, hinted.height()) + assertTrue(hinted.isKnownVersion()) + + val hint = hinted.hint() + assertNotNull(hint) + // Read against the bag's own h, as §7.7 requires. + assertEquals(21, hint.gapBits(4)) + assertFalse(hint.isDestination(4)) + } + + @Test + fun aPayloadTooShortToHoldANonceAndATagIsNotOpened() { + // 12 + 16 bytes before there is any ciphertext at all; anything shorter + // is not this cipher's output. + val short = bag("AAECAwQFBgcICQoL") + assertNull(short.open(key)) + } + + @Test + fun anEventOfThisKindArrivesAsABag() { + val json = bag(opaquePayload).toJson() + assertTrue(Event.fromJson(json) is CyberspaceBagEvent, "kind 33330 is registered in the factory") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt new file mode 100644 index 0000000000..1d4c6a0425 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt @@ -0,0 +1,168 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * §2.2's interleave, against the spec's own consensus locks. + * + * Every vector here is published: the six GPS golden vectors of §9.8 and the three + * hint vectors of §7.7, which between them pin the bit layout, the plane bit, the + * sector shift of §10 and the aligned base of §4.5. None of them was produced by + * this code. + */ +class CyberspaceCoordinateTest { + private val zeros = "0".repeat(64) + + /** §9.8, spec version `2026-03-16-h34-corrected`, altitude 0. */ + private val goldenVectors = + mapOf( + "origin_equator_prime" to "e000000000000000000001200041040208048040000000000000000000000000", + "equator_east_90" to "e000000000000000000000480010410082012010000000000000000000000000", + "equator_west_90" to "c492492492492492492492012482082410480490000000000000000000000000", + "north_pole" to "e000000000000000000000900004924920020000820000920100824920800020", + "london" to "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", + "nyc" to "c4924924924924924924921f79235dae293ada913e78294253a235239a332854", + ) + + @Test + fun everyGoldenVectorSurvivesTheRoundTrip() { + // The mapping that produced these is §9.7's, which this does not implement. + // What it does implement is §2.2, and a coordinate that decodes to three + // axes and back to a different string has the bit layout wrong. + for ((name, hex) in goldenVectors) { + val point = CyberspaceCoordinate.decode(hex) + assertNotNull(point, name) + assertEquals(hex, CyberspaceCoordinate.encode(point), name) + } + } + + @Test + fun theKnownPointOfTheIdeaspaceVectorDecodesToItsStatedAxes() { + // §7.7 states this one's axes outright: "The ideaspace point is + // x = 2^84 + 12345, y = 3 * 2^80 + 777, z = 2^85 - 1 - 4242 on plane 1." + val point = CyberspaceCoordinate.decode("a4b64924924924924924924924924924924924924924924924924d84b60d9c8f") + assertNotNull(point) + assertEquals(CyberspacePlane.IDEASPACE, point.plane) + + // 2^84 is bit 84, the top bit of an 85-bit axis: high = 2^20, low = 12345. + assertEquals(1L shl 20, point.x.high) + assertEquals(12345L, point.x.low) + // 3 * 2^80 sets bits 80 and 81, which are bits 16 and 17 of the high half. + assertEquals((1L shl 16) or (1L shl 17), point.y.high) + assertEquals(777L, point.y.low) + // 2^85 - 1 is every bit set; less 4242. + assertEquals((1L shl 21) - 1, point.z.high) + assertEquals(-1L - 4242L, point.z.low, "the low half runs unsigned and this one has its top bit set") + } + + @Test + fun theSectorsAreTheOnesTheHintVectorsCarry() { + // §7.7's `london_h5_box11`: the hint coordinate and the sector tags a bag + // carrying it MUST also carry. A sector is the axis shifted right by 30 + // (§10), which is the one part of an axis that fits a Long. + val london = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000") + assertNotNull(london) + assertEquals(18014398541305938L, london.x.sector()) + assertEquals(18014398549232983L, london.y.sector()) + assertEquals(18014398509410999L, london.z.sector()) + assertEquals("18014398541305938-18014398549232983-18014398509410999", london.sector()) + + // `ideaspace_h8_y_open` fixes X and Z only; its Y height of 40 is above the + // sector shift, so no `Y` tag, which is a rule about the hint rather than + // about the axis — the axis still has a sector and this checks the two the + // vector publishes. + val idea = CyberspaceCoordinate.decode("a4b64924924924924924924924924924924924924924924924924d8000000001") + assertNotNull(idea) + assertEquals(18014398509481984L, idea.x.sector()) + assertEquals(36028797018963967L, idea.z.sector()) + } + + @Test + fun theHintCoordinateIsThePointWithItsLowBitsCleared() { + // §7.7: the hint's coordinate "MUST be the aligned base: the low `H` bits of + // each axis MUST be zero". `london_h5_box11` is the §9.8 london vector at + // heights 11, 11, 11, so aligning london to 11 must reproduce it exactly. + val london = CyberspaceCoordinate.decode(goldenVectors.getValue("london")) + assertNotNull(london) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", + CyberspaceCoordinate.encode(london.alignedBase(11)), + ) + // And `london_h5_x_exact` is the same point at heights 5, 14, 14 — an + // unequal box, so it is not a single alignedBase call, but X aligned to 5 + // is what its exact axis claims. + assertEquals(london.x.alignedBase(5), CyberspaceCoordinate.decode(goldenVectors.getValue("london"))!!.x.alignedBase(5)) + } + + @Test + fun aligningPastTheAxisEmptiesIt() { + val london = CyberspaceCoordinate.decode(goldenVectors.getValue("london"))!! + // §7.7: "A height of 85 leaves an axis open: the base is 0". + assertEquals(0L, london.x.alignedBase(85).high) + assertEquals(0L, london.x.alignedBase(85).low) + // The boundary at 64, where an axis stops fitting one Long. + assertEquals(0L, london.x.alignedBase(64).low) + assertEquals(london.x.high, london.x.alignedBase(64).high) + assertEquals(london.x, london.x.alignedBase(0)) + } + + @Test + fun thePlaneIsTheLeastSignificantBit() { + // §2.2: "Bit 0 (LSB): plane bit P", and §2.4: 0 is dataspace, 1 ideaspace. + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros)) + assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "1")) + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "e")) + assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "f")) + // The other 255 bits are the axes and say nothing about the plane. + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf("f".repeat(63) + "0")) + // And the plane survives a round trip on its own. + assertEquals("f".repeat(63) + "1", CyberspaceCoordinate.encode(CyberspaceCoordinate.decode("f".repeat(63) + "1")!!)) + } + + @Test + fun theOriginIsEveryAxisAtZero() { + val point = CyberspaceCoordinate.decode(zeros) + assertNotNull(point) + assertEquals(0L, point.x.high + point.x.low + point.y.high + point.y.low + point.z.high + point.z.low) + assertEquals(zeros, CyberspaceCoordinate.encode(point)) + } + + @Test + fun anythingThatIsNotThirtyTwoBytesOfLowercaseHexIsNotACoordinate() { + assertTrue(CyberspaceCoordinate.isWellFormed(zeros)) + assertFalse(CyberspaceCoordinate.isWellFormed("")) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1))) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros + "0")) + // §7.6 and §8 both say lowercase; an uppercase one is somebody else's + // convention and is not silently accepted. + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "A")) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "g")) + assertNull(CyberspaceCoordinate.planeOf("not a coordinate")) + assertNull(CyberspaceCoordinate.decode("not a coordinate")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt new file mode 100644 index 0000000000..a5f81b59bf --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt @@ -0,0 +1,223 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * §7.7's hinted box, against the three golden vectors the section publishes. + * + * Those vectors are produced by `hint-reference.py` and they pin more than a + * parser: each gives a point, a bag height and three hint heights, and states + * the coordinate the aligned base must come out as and the sector tags the bag + * must then carry. So building a hint from the point reproduces the published + * tag byte for byte, or the alignment is wrong. + */ +class CyberspaceHintTest { + /** §9.8's london vector, which the first two hint vectors are taken from. */ + private val london = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940" + + /** §7.7: "The ideaspace point is x = 2^84 + 12345, y = 3 * 2^80 + 777, z = 2^85 - 1 - 4242 on plane 1." */ + private val ideaspace = "a4b64924924924924924924924924924924924924924924924924d84b60d9c8f" + + private fun hintOf( + coordinate: String, + x: Int, + y: Int, + z: Int, + ): CyberspaceHint { + val point = CyberspaceCoordinate.decode(coordinate) + assertNotNull(point) + return CyberspaceHint( + CyberspacePoint(point.x.alignedBase(x), point.y.alignedBase(y), point.z.alignedBase(z), point.plane), + x, + y, + z, + ) + } + + @Test + fun londonAtElevenReproducesItsPublishedTag() { + // `london_h5_box11`: bag at height 5, heights 11/11/11, 2^18 candidates. + val hint = hintOf(london, 11, 11, 11) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(18, hint.gapBits(5)) + assertEquals(1L shl 18, hint.candidates(5)) + assertFalse(hint.isDestination(5)) + + assertEquals( + listOf( + listOf("X", "18014398541305938"), + listOf("Y", "18014398549232983"), + listOf("Z", "18014398509410999"), + listOf("S", "18014398541305938-18014398549232983-18014398509410999"), + ), + hint.sectorTags().map { it.toList() }, + ) + } + + @Test + fun londonWithAnExactAxisReproducesItsPublishedTag() { + // `london_h5_x_exact`: heights 5/14/14. §7.7 calls this "a + // two-dimensional hunt: X is exact, so the seeker sweeps a 2^9 by 2^9 + // slab of height-5 regions" — still 2^18, reached a different way. + val hint = hintOf(london, 5, 14, 14) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c749041240000", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(18, hint.gapBits(5)) + // Every axis is at or under the sector shift, so all four tags, and the + // same ones as the cube: a sector is decided by bits above 30, which + // neither height touches. + assertEquals(4, hint.sectorTags().size) + assertEquals("18014398541305938-18014398549232983-18014398509410999", hint.sectorTags()[3][1]) + } + + @Test + fun anOpenAxisReproducesItsTagAndDropsItsSector() { + // `ideaspace_h8_y_open`: bag at height 8, heights 12/40/12, 2^40 + // candidates — "far beyond any search; that hint tells the seeker where + // to travel". Hy is above the sector shift, so no `Y` and no `S`. + val hint = hintOf(ideaspace, 12, 40, 12) + assertEquals( + "a4b64924924924924924924924924924924924924924924924924d8000000001", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(40, hint.gapBits(8)) + assertEquals(1L shl 40, hint.candidates(8)) + + assertEquals( + listOf(listOf("X", "18014398509481984"), listOf("Z", "36028797018963967")), + hint.sectorTags().map { it.toList() }, + ) + } + + @Test + fun aTagRoundTripsThroughTheReader() { + for (hint in listOf(hintOf(london, 11, 11, 11), hintOf(london, 5, 14, 14), hintOf(ideaspace, 12, 40, 12))) { + assertEquals(hint, CyberspaceHint.read(hint.toTag())) + assertEquals(hint, CyberspaceHint.read(arrayOf(hint.toTag()), bagHeight = 5)) + } + } + + @Test + fun aHintAtTheBagsOwnHeightIsADestinationRatherThanASearch() { + // §7.7: "Three heights equal to `h` name the region itself: the hint is + // then a destination the seeker can compute or walk to directly, not a + // search." + val hint = hintOf(london, 5, 5, 5) + assertTrue(hint.isDestination(5)) + assertEquals(0, hint.gapBits(5)) + assertEquals(1L, hint.candidates(5)) + assertEquals(3L, hint.axisTrees(5), "one tree per axis and no more") + } + + @Test + fun aSweepCostsTreesPerAxisAndCombinesPerCandidate() { + // The decomposition of §4.7: the axes are independent up to the combine, + // so an even box needs a cube root of its candidates in trees. + val hint = hintOf(london, 11, 11, 11) + assertEquals(1L shl 18, hint.candidates(5)) + assertEquals(3L * (1L shl 6), hint.axisTrees(5), "2^6 bases on each of three axes") + } + + @Test + fun aSweepBeyondCountingSaysSoRatherThanWrapping() { + // A sector-only hint on a shallow bag is a gap of 75, "about 2^75 + // candidates, which no one will sweep". Nothing holds that, and a + // silently wrapped count would be quoted to a reader as a small number. + val hint = hintOf(london, 30, 30, 30) + assertEquals(75, hint.gapBits(5)) + assertNull(hint.candidates(5)) + + // The trees, though, are still countable, and that asymmetry is the + // point of reporting them apart: 25 bits on each axis is a hundred + // million Cantor trees against 2^75 combines. Both say the sweep is + // hopeless; only one of them can say it with a number. + assertEquals(3L * (1L shl 25), hint.axisTrees(5)) + + // Past 40 bits on a single axis even the trees stop fitting anything + // worth quoting, and that is where null starts. + assertNull(hintOf(london, 50, 30, 30).axisTrees(5)) + } + + @Test + fun containmentIsTwoChecksPerAxis() { + val box = hintOf(london, 11, 11, 11) + val point = CyberspaceCoordinate.decode(london)!! + + // The region the hint was built around, at the bag's height. + assertTrue(box.contains(point.alignedBase(5), 5)) + // A region one step outside the box on X. + val outside = CyberspacePoint(CyberspaceAxis(point.x.high, point.x.low + (1L shl 11)), point.y, point.z, point.plane) + assertFalse(box.contains(outside.alignedBase(5), 5)) + // §7.7: the claim is about a plane as well as a place. + val elsewhere = CyberspacePoint(point.x, point.y, point.z, CyberspacePlane.IDEASPACE) + assertFalse(box.contains(elsewhere.alignedBase(5), 5)) + // "A box smaller than the region could not contain it." + assertFalse(box.contains(point.alignedBase(12), 12)) + } + + @Test + fun aBrokenHintIsAbsentRatherThanFatal() { + // §7.7: "A `hint` tag that breaks any rule above MUST be treated as + // absent... A bad hint never invalidates the bag." + val good = hintOf(london, 11, 11, 11).toTag() + + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "11", "11")), "wrong arity") + assertNull(CyberspaceHint.read(arrayOf("hint", "nonsense", "11", "11", "11")), "bad hex") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1].uppercase(), "11", "11", "11")), "not lowercase") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "86", "11", "11")), "height past the axis") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "-1", "11", "11")), "signed") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "011", "11", "11")), "leading zero") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "", "11", "11")), "empty") + // The base is not aligned to the heights it claims. + assertNull(CyberspaceHint.read(arrayOf("hint", london, "11", "11", "11")), "unaligned base") + // "each hint height MUST therefore be at least `h`". + assertNull(CyberspaceHint.read(arrayOf(good), bagHeight = 12), "box smaller than the region") + assertNotNull(CyberspaceHint.read(arrayOf(good), bagHeight = 11), "exactly the region is allowed") + + // "A bag MUST carry at most one `hint` tag." + assertNull(CyberspaceHint.read(arrayOf(good, good)), "two hints") + assertNull(CyberspaceHint.read(arrayOf(arrayOf("d", "abc"))), "no hint at all") + } + + @Test + fun anAxisLeftFullyOpenHasABaseOfZero() { + // §7.7: "A height of 85 leaves an axis open: the base is 0, the box + // spans the whole axis, and the hint says nothing about that coordinate." + val hint = hintOf(london, 85, 11, 11) + assertEquals(0L, hint.base.x.high) + assertEquals(0L, hint.base.x.low) + assertEquals(hint, CyberspaceHint.read(hint.toTag())) + // And an open axis is far past the sector shift, so it carries no tag. + assertEquals(listOf("Y", "Z"), hint.sectorTags().map { it[0] }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt new file mode 100644 index 0000000000..61b6a4278b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt @@ -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.quartz.cyberspace + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * One model unit as a length, checked against the reference the whole way up. + * + * Every value DECK-0003 §1.8 permits for `unit` — 0 to 84 — is here, taken + * from `sno-core/scale.ts` run over the same range, because the point of the + * readout is that the same object reads the same size in both clients. The + * boundaries are where the two could drift: where the measure changes, where a + * figure rounds up past its own magnitude, and `unit: 28`, which is 3.125 cm + * exactly — a tie, and the one entry where a language's default rounding shows. + * ECMAScript's `toPrecision` takes the larger, so 3.13, and `roundToLong` here + * rounds half up and agrees. + */ +class CyberspaceScaleTest { + private val reference = + ( + "116 pm,233 pm,466 pm,931 pm,1.86 nm,3.73 nm,7.45 nm,14.9 nm,29.8 nm,59.6 nm,119 nm," + + "238 nm,477 nm,954 nm,1.91 µm,3.81 µm,7.63 µm,15.3 µm,30.5 µm,61 µm," + + "122 µm,244 µm,488 µm,977 µm,1.95 mm,3.91 mm,7.81 mm,1.56 cm,3.13 cm,6.25 cm," + + "0.125 m,0.25 m,0.5 m,1 m,2 m,4 m,8 m,16 m,32 m,64 m,128 m,256 m,512 m,1.02 km,2.05 km," + + "4.1 km,8.19 km,16.4 km,32.8 km,65.5 km,131 km,262 km,524 km,1.05 Mm,2.1 Mm,4.19 Mm," + + "8.39 Mm,16.8 Mm,33.6 Mm,67.1 Mm,134 Mm,268 Mm,537 Mm,0.00718 AU,0.0144 AU,0.0287 AU," + + "0.0574 AU,0.115 AU,0.23 AU,0.459 AU,0.919 AU,1.84 AU,3.67 AU,7.35 AU,14.7 AU,29.4 AU," + + "58.8 AU,118 AU,235 AU,470 AU,941 AU,1880 AU,3760 AU,7530 AU,15100 AU" + ).split(",") + + @Test + fun everyUnitReadsAsTheReferenceReadsIt() { + assertEquals(SnoPayload.MAX_UNIT + 1, reference.size, "the table should cover every unit the deck allows") + for (unit in reference.indices) { + assertEquals(reference[unit], CyberspaceScale.describeUnit(unit), "unit $unit") + } + } + + @Test + fun theAnchorsAreTheOnesTheSpecStates() { + // §9.3: "Cantor Height 33 = 1 meter", and 34 is the two metres the + // scale was calibrated on. + assertEquals("1 m", CyberspaceScale.describeUnit(33)) + assertEquals("2 m", CyberspaceScale.describeUnit(34)) + // §9.2: a gibson is 2^-33 m, "roughly the size of a hydrogen atom". + assertEquals("116 pm", CyberspaceScale.describeUnit(0)) + assertEquals(1.0, CyberspaceScale.unitInMetres(33), 1e-12) + } + + @Test + fun aUnitOutsideTheDeckIsHeldAtTheEdgeRatherThanOverflowing() { + // The parser rejects these long before here, so this is only about not + // producing a nonsense string if some other caller asks. + assertEquals(CyberspaceScale.describeUnit(0), CyberspaceScale.describeUnit(-5)) + assertEquals(CyberspaceScale.describeUnit(SnoPayload.MAX_UNIT), CyberspaceScale.describeUnit(9999)) + assertTrue(CyberspaceScale.unitInMetres(9999).isFinite()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt new file mode 100644 index 0000000000..8fa81b7469 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt @@ -0,0 +1,146 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * §4's Cantor roots and §7.2's key derivation, against the reference + * implementation. + * + * Every expectation below came out of `cyberspace-cli`'s own + * `compute_subtree_cantor`, `cantor_pair` and `int_to_bytes_be_min`, run over + * the four coordinates at five heights each. They are keys: if this file passes, + * a bag the reference hid is one Amethyst can open, and if it fails they are two + * different networks that happen to share a spec. + * + * The **key** is the vector rather than `region_n` because it pins `region_n` + * exactly in 32 bytes — a root at height 10 is eleven kilobytes of hex — and + * because it also pins the byte encoding, which is the part most likely to + * drift: the reference writes minimal big-endian, and a platform big integer + * that adds a sign byte produces a different key for one number in two. + */ +class RegionKeyTest { + /** `name | height | key | lookup_id`, from the reference. */ + private val reference = + listOf( + "london|0|c9fed7928825bb4192386ad339bfe73df70de5918e4395f29a97762458bfb972|c55b1e044f016744c45b6dc8080e6e3825f9d88b412e2d1a6becad4de8c5f5ef", + "london|1|6ea9f6b310442cced2ae706468aa90c9c5e217483b727b819c212c231f99b17f|24f83bebbf580937f066df5283abe7386f9abbc38f567b8442fdc7e591ffb053", + "london|4|314ade123f63601cc69a0addf455ea5c2d84ea848b84285aee5a00941e316ab4|a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916", + "london|8|28939afc70712ce271b74ac7f5b9ed8339c955311cc289c516c63fa6e7a7a489|188ae4d5dccd60a27207f1d46a83959e002ec99cdeec717873ed5dc1a0fd5f69", + "london|10|83f219e105c3011e0f1b06f1f0fc053b437c2edb5826ff707dbb2cc8ef7dd550|a94422ee1b5423639dac61c203ee3c308c47b86470065587f38abf6a359d946e", + "nyc|0|1232b3a383492a4051fc7ee67d0332b42fcd113e834f05203f54c709e0f9bc78|80c73abdf82ab69d3e6795b13e0271321bf7ae1348007adf909b6b1e5f80ecfa", + "nyc|1|847cec141e1f645d296bd3c80bcc32f8b5ef60868f34533958d8917c117d8204|ee1802e91efeea676cc2955d9af0405dba74ad247c1833334efd2d52efb6cb04", + "nyc|4|bdb14b2c226797a79a2a808cbbf6126ddb5eb4bce27c8880c9bd668c853db4d1|4b3b0c9c1103ca97cff6124dce1752cdfec03a7358e28f6dd734ceb21e32ed3e", + "nyc|8|71cadf83822c69daafff9ea77ccfe4fb3b9d113b6f2e3bcd5f270862145807a6|1cf5bb35aa98c110b37add7d0bdfbd42474fe7c0cf523f569b06225ab8b0761b", + "nyc|10|a947d0322e2afad8c4852e2045c00f277bee246f41477212067b9c01f9cb9192|d464d48d1b85eb216d94b3b56654e8ce1a0f7b5c3e6a643096c88632e477aca4", + "origin|0|692329a8a9893517bfaaa0c1e27f597bc6b498d1371a4c866aa89424f54fe633|6eaedee401f51b2d54c51f86a603888dc1decb14dc872d26a0ffd6f4a4369136", + "origin|1|cb140125e63937b960fe6ed4a8ef6c9e801918c53a6b03b74897f245e01d12e4|21263dc0e2d46b450aa471aa34114ce21682818c5fbb285b06713f0ad3b26485", + "origin|4|a41a79fe0e41b1d5519fa71ab36fa0249f3411da72b810471e22274b09647de2|cb5e37e49dbdd02bae07347220635443739704d4177e84fa5b8ff8efbd2d8ce3", + "origin|8|fd1c6fc0d687db7edfc8747d5e844b79b3bfff1ebe206305e6f73d6ad27697fa|83ffb82c33758464c11482736525f14273eacaa4b0946119fb7202c04a7d30ec", + "origin|10|7d439417987c3b4c4cdc3bc9d76e1e09986817b9a5f6119d4f8fb4e858572ba3|5e6d4f1d57f9bc65bb07b6aeac02dd39f9a893790bb9788887d9d5f1fdad50c7", + "ideaspace|0|9d8de1257072196c30d90d540154a8259f1804dddc7dbcb3d99499cc48dec355|3350bd0e23d4b80b6a0e1070169f3e3252ce2d41e1b09134e14fea60fe7aa122", + "ideaspace|1|1f98a44ace1dbc67c7c45f5de8b010f6c551ba80f4022e9958c5eccf3640746c|ab1fda3988d871b25d45b93e28f971284e57da83ffb3e0e03c03fcbe2ede1ca8", + "ideaspace|4|bd26b0a550956d90161c21bc96e0adeb3ac262eea59345705eb26adf63345e56|c9d141d4f23590036f9cd3b82de1cd00faa512dc11937e5fc30b18e9ad382150", + "ideaspace|8|ee7c2c7af8c082705533436aa577e28f9020dc2eaf3bab4425c13560df67d306|2e82777cc6a759f52e76004b312806c1ddd2ee7642260f2cc3e52f1873b6aaef", + "ideaspace|10|da3f756d61498b59f00cc179a7c643e30d24395fc437091445f686bce64994fb|36b601485a2d28eb41b21bb20e05782106ae8753982196de5f95c3e03dad66a2", + ) + + private val coordinates = + mapOf( + "london" to "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", + "nyc" to "c4924924924924924924921f79235dae293ada913e78294253a235239a332854", + "origin" to "e000000000000000000001200041040208048040000000000000000000000000", + "ideaspace" to "a4b64924924924924924924924924924924924924924924924924d84b60d9c8f", + ) + + @Test + fun everyRegionKeyMatchesTheReference() { + for (row in reference) { + val (name, height, key, lookupId) = row.split("|") + val point = CyberspaceCoordinate.decode(coordinates.getValue(name)) + assertNotNull(point, name) + + val material = RegionKey.at(point, height.toInt()) + assertEquals(key, material.decryptionKey.toHexKey(), "$name at height $height: the key") + assertEquals(lookupId, material.lookupId, "$name at height $height: the lookup id") + } + } + + private operator fun List.component4() = this[3] + + @Test + fun theCantorPairIsTheOneTheSpecWrites() { + // §4.6: cantor_pair(a, b) = (a + b)(a + b + 1) / 2 + b, worked by hand. + // (3 + 5) * 9 / 2 + 5 = 41. + assertEquals(UBigInt.of(41), CantorTree.cantorPair(UBigInt.of(3), UBigInt.of(5))) + assertEquals(UBigInt.of(0), CantorTree.cantorPair(UBigInt.ZERO, UBigInt.ZERO)) + // It is a bijection, so no two pairs share a value. The classic witness + // is that it is not symmetric. + assertTrue(CantorTree.cantorPair(UBigInt.of(5), UBigInt.of(3)) != CantorTree.cantorPair(UBigInt.of(3), UBigInt.of(5))) + } + + @Test + fun aHeightOfZeroIsTheBaseItself() { + // The reference returns `base` unchanged at height 0, so a region of one + // leaf is that leaf's own number and costs nothing. + assertEquals(UBigInt.of(12345), CantorTree.subtreeRoot(UBigInt.of(12345), 0)) + } + + @Test + fun theSpecsOwnOneDimensionalExampleHolds() { + // §4.5: "LCA(0, 3) => subtree [0..3] => root = 228", and the same root + // for LCA(1, 2) and LCA(0, 2) — all three movements see one region, + // "which is exactly what enables location-based discovery". + assertEquals(UBigInt.of(228), CantorTree.subtreeRoot(UBigInt.ZERO, 2)) + } + + @Test + fun aSubtreeTallerThanTheCeilingIsRefusedRatherThanAttempted() { + // Both references raise instead of trying, and for the same reason: one + // height past the ceiling is twice the leaves and a root twice as wide, + // and the number is a stranger's to choose. + assertFailsWith { + CantorTree.subtreeRoot(UBigInt.ZERO, CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT + 1) + } + assertFailsWith { CantorTree.subtreeRoot(UBigInt.ZERO, -1) } + // And a caller that means it can say so. + assertEquals(UBigInt.of(228), CantorTree.subtreeRoot(UBigInt.ZERO, 2, maxComputeHeight = 2)) + } + + @Test + fun theLcaHeightIsTheBitLengthOfTheDifference() { + // §4.5: "h = find_lca_height(v1, v2)", bit_length(v1 XOR v2). Moving + // from 0 to 5 gives 3, from 4 to 7 gives 2. + fun axis(v: Long) = CyberspaceAxis(0L, v) + assertEquals(3, CantorTree.lcaHeight(axis(0), axis(5))) + assertEquals(2, CantorTree.lcaHeight(axis(4), axis(7))) + assertEquals(0, CantorTree.lcaHeight(axis(9), axis(9))) + // Across the 64-bit split, where an axis stops fitting one Long. + assertEquals(65, CantorTree.lcaHeight(CyberspaceAxis(1L, 0L), CyberspaceAxis(0L, 0L))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt new file mode 100644 index 0000000000..3b7754a539 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt @@ -0,0 +1,138 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * §7.7's sweep: the thing a seeker actually does with a hint. + * + * The test that matters is the round trip — hide a region, hint a box around it, + * sweep the box, and find the region's own `lookup_id` among the candidates. + * Everything else about this feature is preparation for that one answer. + */ +class RegionSweepTest { + private val london = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940")!! + + private fun boxAround( + point: CyberspacePoint, + height: Int, + ) = CyberspaceHint( + CyberspacePoint(point.x.alignedBase(height), point.y.alignedBase(height), point.z.alignedBase(height), point.plane), + height, + height, + height, + ) + + @Test + fun aSweepFindsTheRegionItsBoxWasDrawnAround() { + // A bag hidden at height 4, hinted with a box one height up: 2^3 = 8 + // candidates, one of which is the region itself. + val bagHeight = 4 + val hint = boxAround(london, 5) + assertEquals(3, hint.gapBits(bagHeight)) + + val wanted = RegionKey.at(london, bagHeight).lookupId + val swept = RegionSweep.of(hint, bagHeight).toList() + + assertEquals(8, swept.size, "2^((5-4) * 3) candidates") + assertTrue(swept.any { it.lookupId == wanted }, "the region the box was drawn around is in the box") + assertEquals(swept.size, swept.map { it.lookupId }.toSet().size, "every candidate is its own region") + assertTrue(swept.all { it.height == bagHeight }) + } + + @Test + fun aDestinationHintSweepsExactlyTheRegionItNames() { + // §7.7: "Three heights equal to `h` name the region itself: the hint is + // then a destination the seeker can compute or walk to directly, not a + // search." + val bagHeight = 6 + val hint = boxAround(london, bagHeight) + assertTrue(hint.isDestination(bagHeight)) + + val swept = RegionSweep.of(hint, bagHeight).toList() + assertEquals(1, swept.size) + assertEquals(RegionKey.at(london, bagHeight).lookupId, swept[0].lookupId) + } + + @Test + fun anUnevenBoxSweepsTheProductOfItsAxes() { + // A slab: X pinned exactly, two coarse axes. §7.7 calls this "a + // two-dimensional hunt". + val bagHeight = 3 + val hint = + CyberspaceHint( + CyberspacePoint(london.x.alignedBase(3), london.y.alignedBase(5), london.z.alignedBase(5), london.plane), + 3, + 5, + 5, + ) + assertEquals(4, hint.gapBits(bagHeight)) + + val swept = RegionSweep.of(hint, bagHeight).toList() + assertEquals(16, swept.size, "1 * 4 * 4") + assertTrue(swept.any { it.lookupId == RegionKey.at(london, bagHeight).lookupId }) + } + + @Test + fun nothingHappensUntilSomethingPulls() { + // The budget is the only defence against a hint a stranger chose, so a + // sweep has to be cold: building the sequence must not build a tree. + val hint = boxAround(london, 12) + val sequence = RegionSweep.of(hint, 4) + // 2^24 candidates. Constructing this is free; taking one is not, and + // taking all of them is what the caller's budget is for. + assertEquals(24, hint.gapBits(4)) + assertNotNull(sequence) + } + + @Test + fun aBoxSmallerThanTheRegionIsRefused() { + // §7.7: "a box smaller than the region could not contain it". + assertFailsWith { RegionSweep.of(boxAround(london, 4), 8).first() } + } + + @Test + fun anAxisPastWhatFitsInMemoryIsRefusedRatherThanAttempted() { + // Not a judgement about difficulty — the hider sets that — but about + // the roots of one axis being held while the other two are walked. + val hint = boxAround(london, RegionSweep.MAX_AXIS_STEPS + 5) + assertFailsWith { RegionSweep.of(hint, 0).first() } + } + + @Test + fun steppingAcrossTheAxisSplitStaysOnTheLattice() { + // An axis is two Longs, so a box whose candidates cross the 64-bit + // boundary is where a carry would go missing — and a wrong base is a + // key that opens nothing, silently. + val low = CyberspaceAxis(0L, -1L - 3L) // four steps below 2^64 + val point = CyberspacePoint(low, low, low, CyberspacePlane.DATASPACE) + val hint = CyberspaceHint(CyberspacePoint(low.alignedBase(2), low.alignedBase(2), low.alignedBase(2), point.plane), 2, 2, 2) + + val swept = RegionSweep.of(hint, 0).toList() + assertEquals(64, swept.size, "4 * 4 * 4") + assertEquals(swept.size, swept.map { it.lookupId }.toSet().size, "no two candidates collided across the split") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt new file mode 100644 index 0000000000..b9b0031b99 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt @@ -0,0 +1,126 @@ +/* + * 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 com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The drawing gate of `CYBERSPACE_V2.md` §8.10: "a client MUST NOT draw an + * avatar event that is not paid, or that carries content it cannot read". + * + * Both conditions are required — the committed target must cover the work the + * payload owes, AND the id must carry that many leading zero bits — and the + * interesting case is the one where only the second fails. + */ +class SnoAvatarPaidTest { + /** A shape that owes exactly the 16-bit floor. */ + private val floorShape = + """{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""" + + private fun idWithRank(bits: Int): String { + val zeros = bits / 4 + val remainder = bits % 4 + val stopper = + when (remainder) { + 0 -> "f" + 1 -> "4" + 2 -> "2" + else -> "1" + } + val head = "0".repeat(zeros) + stopper + return head + "f".repeat(64 - head.length) + } + + private fun avatar( + content: String, + committed: Int?, + idBits: Int, + ) = SnoAvatarEvent( + idWithRank(idBits), + "11".repeat(32), + 0L, + committed?.let { arrayOf(arrayOf("nonce", "1", it.toString())) } ?: emptyArray(), + content, + "22".repeat(64), + ) + + @Test + fun theIdHelperProducesTheRankItClaims() { + listOf(12, 16, 20, 30).forEach { + assertEquals(it, PoWRankEvaluator.calculatePowRankOf(idWithRank(it)), "rank for $it bits") + } + } + + @Test + fun theFloorShapeOwesSixteenBits() { + assertEquals(16, SnoAvatarWork.required(SnoParser.parse(floorShape).payloadOrNull()!!)) + } + + @Test + fun anAvatarMinedToItsCommitmentIsPaid() { + assertTrue(avatar(floorShape, committed = 16, idBits = 16).isPaid()) + assertTrue(avatar(floorShape, committed = 16, idBits = 24).isPaid(), "over-mining is fine") + } + + @Test + fun emptyContentIsTheDefaultAvatarAndOwesNothing() { + val default = avatar("", committed = null, idBits = 0) + assertTrue(default.isDefaultAvatar()) + assertTrue(default.isPaid()) + } + + @Test + fun anAvatarWithoutANonceTagIsNotPaid() { + // §8.10 requires the tag: committing the target before mining is what + // stops a lucky id being claimed against a lower bar than it was mined + // for, so an uncommitted avatar has not paid however long its id is. + assertFalse(avatar(floorShape, committed = null, idBits = 32).isPaid()) + } + + @Test + fun anAvatarCommittingLessThanItOwesIsNotPaid() { + assertFalse(avatar(floorShape, committed = 8, idBits = 32).isPaid()) + } + + @Test + fun anIdShortOfItsOwnCommitmentIsNotPaid() { + // The case a naive check gets wrong. Event.pow() is + // PoWRankEvaluator.compute(id, committed), which returns + // min(actualRank, committed) — here min(20, 30) = 20, which clears the + // 16 bits the shape owes while the id plainly falls short of the 30 the + // publisher committed to. + val event = avatar(floorShape, committed = 30, idBits = 20) + + assertEquals(20, PoWRankEvaluator.compute(event.id, 30), "the shortcut would say 20...") + assertTrue(20 >= SnoAvatarWork.required(event.sno().payloadOrNull()!!), "...which clears the required 16...") + assertFalse(event.isPaid(), "...but §8.10 needs the id to carry the committed 30") + } + + @Test + fun anAvatarWhoseContentCannotBeReadIsNotPaid() { + assertFalse(avatar("not json at all", committed = 32, idBits = 32).isPaid()) + assertFalse(avatar("""{"v":9}""", committed = 32, idBits = 32).isPaid()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt new file mode 100644 index 0000000000..3fbd5f7678 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt @@ -0,0 +1,194 @@ +/* + * 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 + +/** + * The avatar-work golden vectors from the reference implementation. + * + * `CYBERSPACE_V2.md` §8.10 says its two reference implementations, `avatar.ts` + * in cyberspace-core and `cyberspace_core/avatar.py` in cyberspace-cli, are + * "pinned to one set of golden vectors". This is that set, read out of + * `tests/fixtures/avatar_work.json` in cyberspace-cli (MIT), so our ladder is + * checked against the implementation the spec points at and not only against + * the pseudocode we both read. + * + * Two of the fixtures need a word: + * - **"half gibson, ticks"** is the case a careless reader gets wrong. Its + * vertex is `[-1, -1, -1]` with ticks `[60, 60, 60]`, which is -0.5 of a unit, + * not -1.5: the whole part is the floor and the ticks count up from it, so the + * ticks must be added to the signed whole before the magnitude is taken. + * - **"detailed but small"** indexes faces past the end of its vertex list, + * because the work formula only counts faces and never looks at them. §1.9 + * would refuse that payload, so this keeps the face count and substitutes + * indices the parser accepts, which is the same input as far as the formula + * is concerned. + */ +class SnoAvatarWorkGoldenVectorsTest { + private data class Vector( + val name: String, + val reach: Double, + val required: Int, + val substitutedFaces: Boolean, + val json: String, + ) + + private val vectors = + listOf( + Vector( + name = "one gibson, plain", + reach = 1.0, + required = 16, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"one gibson, plain\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[0,1,0],[0,0,1]],\"colors\":[225,225,225" + + ",225],\"faces\":[[0,1,2],[0,2,3]]}", + ), + Vector( + name = "half gibson, ticks", + reach = 0.5, + required = 16, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"half gibson, ticks\",\"unit\":0,\"mode\":\"points\",\"vertices\":[[0,0,0],[-1,-1,-1]],\"colors\":[225,225],\"faces\":[]," + + "\"ticks\":[[60,60,60],[60,60,60]]}", + ), + Vector( + name = "two gibsons", + reach = 2.0, + required = 18, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"two gibsons\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-2,-2,-2],[-2,-2,2],[-2,2,-2],[-2,2,2],[2,-2,-2],[2,-2,2],[2," + + "2,-2],[2,2,2]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6],[3,7,6],[0,2," + + "4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "four gibsons", + reach = 4.0, + required = 20, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"four gibsons\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-4,-4,-4],[-4,-4,4],[-4,4,-4],[-4,4,4],[4,-4,-4],[4,-4,4],[4" + + ",4,-4],[4,4,4]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6],[3,7,6],[0,2" + + ",4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "sixteen gibsons via unit", + reach = 16.0, + required = 24, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"sixteen gibsons via unit\",\"unit\":2,\"mode\":\"solid\",\"vertices\":[[-4,-4,-4],[-4,-4,4],[-4,4,-4],[-4,4,4],[4,-4,-4]" + + ",[4,-4,4],[4,4,-4],[4,4,4]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6]," + + "[3,7,6],[0,2,4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "a thousand and twenty-four gibsons", + reach = 1024.0, + required = 36, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"a thousand and twenty-four gibsons\",\"unit\":10,\"mode\":\"solid\",\"vertices\":[[-1,-1,-1],[-1,-1,1],[-1,1,-1],[-1,1,1" + + "],[1,-1,-1],[1,-1,1],[1,1,-1],[1,1,1]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4," + + "5],[2,3,6],[3,7,6],[0,2,4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "detailed but small", + reach = 1.0, + required = 28, + substitutedFaces = true, + json = + "{\"v\":2,\"name\":\"detailed but small\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-" + + "1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0" + + ",0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-" + + "1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1," + + "0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1]" + + ",[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1]," + + "[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1]," + + "[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1" + + ",-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-" + + "1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1," + + "0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0" + + "],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0" + + "],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0]]" + + ",\"colors\":[225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,2,3]" + + ",[2,3,4],[3,4,5],[4,5,6],[5,6,7],[6,7,8],[7,8,9],[8,9,10],[9,10,11],[10,11,12],[11,12,13],[12,13,14],[13,14,15],[14,15,16],[15,16,17],[16,17" + + ",18],[17,18,19],[18,19,20],[19,20,21],[20,21,22],[21,22,23],[22,23,24],[23,24,25],[24,25,26],[25,26,27],[26,27,28],[27,28,29],[28,29,30],[29" + + ",30,31],[30,31,32],[31,32,33],[32,33,34],[33,34,35],[34,35,36],[35,36,37],[36,37,38],[37,38,39],[38,39,40],[39,40,41],[40,41,42],[41,42,43]," + + "[42,43,44],[43,44,45],[44,45,46],[45,46,47],[46,47,48],[47,48,49],[48,49,50],[49,50,51],[50,51,52],[51,52,53],[52,53,54],[53,54,55],[54,55,5" + + "6],[55,56,57],[56,57,58],[57,58,59],[58,59,60],[59,60,61],[60,61,62],[61,62,63],[62,63,64],[63,64,65],[64,65,66],[65,66,67],[66,67,68],[67,6" + + "8,69],[68,69,70],[69,70,71],[70,71,72],[71,72,73],[72,73,74],[73,74,75],[74,75,76],[75,76,77],[76,77,78],[77,78,79],[78,79,80],[79,80,81],[8" + + "0,81,82],[81,82,83],[82,83,84],[83,84,85],[84,85,86],[85,86,87],[86,87,88],[87,88,89],[88,89,90],[89,90,91],[90,91,92],[91,92,93],[92,93,94]" + + ",[93,94,95],[94,95,96],[95,96,97],[96,97,98],[97,98,99],[98,99,100],[99,100,101],[100,101,102],[101,102,103],[102,103,104],[103,104,105],[10" + + "4,105,106],[105,106,107],[106,107,108],[107,108,109],[108,109,110],[109,110,111],[110,111,112],[111,112,113],[112,113,114],[113,114,115],[11" + + "4,115,116],[115,116,117],[116,117,118],[117,118,119],[118,119,120],[119,120,121],[120,121,122],[121,122,123],[122,123,124],[123,124,125],[12" + + "4,125,126],[125,126,127],[126,127,128],[127,128,129],[128,129,130],[129,130,131],[130,131,132],[131,132,133],[132,133,134],[133,134,135],[13" + + "4,135,136],[135,136,137],[136,137,138],[137,138,139],[138,139,140],[139,140,141],[140,141,142],[141,142,143],[142,143,144],[143,144,145],[14" + + "4,145,146],[145,146,147],[146,147,148],[147,148,149],[148,149,150],[149,150,151],[150,151,152],[151,152,153],[152,153,154],[153,154,155],[15" + + "4,155,156],[155,156,157],[156,157,158],[157,158,159],[158,159,160],[159,160,161],[160,161,162],[161,162,163],[162,163,164],[163,164,165],[16" + + "4,165,166],[165,166,167],[166,167,168],[167,168,169],[168,169,170],[169,170,171],[170,171,172],[171,172,173],[172,173,174],[173,174,175],[17" + + "4,175,176],[175,176,177],[176,177,178],[177,178,179],[178,179,180],[179,180,181],[180,181,182],[181,182,183],[182,183,184],[183,184,185],[18" + + "4,185,186],[185,186,187],[186,187,188],[187,188,189],[188,189,190],[189,190,191],[190,191,192],[191,192,193],[192,193,194],[193,194,195],[19" + + "4,195,196],[195,196,197],[196,197,198],[197,198,199],[198,199,0],[199,0,1],[0,1,2],[1,2,3],[2,3,4],[3,4,5],[4,5,6],[5,6,7],[6,7,8],[7,8,9],[" + + "8,9,10],[9,10,11],[10,11,12],[11,12,13],[12,13,14],[13,14,15],[14,15,16],[15,16,17],[16,17,18],[17,18,19],[18,19,20],[19,20,21],[20,21,22],[" + + "21,22,23],[22,23,24],[23,24,25],[24,25,26],[25,26,27],[26,27,28],[27,28,29],[28,29,30],[29,30,31],[30,31,32],[31,32,33],[32,33,34],[33,34,35" + + "],[34,35,36],[35,36,37],[36,37,38],[37,38,39],[38,39,40],[39,40,41],[40,41,42],[41,42,43],[42,43,44],[43,44,45],[44,45,46],[45,46,47],[46,47" + + ",48],[47,48,49],[48,49,50],[49,50,51],[50,51,52],[51,52,53],[52,53,54],[53,54,55],[54,55,56],[55,56,57],[56,57,58],[57,58,59],[58,59,60],[59" + + ",60,61],[60,61,62],[61,62,63],[62,63,64],[63,64,65],[64,65,66],[65,66,67],[66,67,68],[67,68,69],[68,69,70],[69,70,71],[70,71,72],[71,72,73]," + + "[72,73,74],[73,74,75],[74,75,76],[75,76,77],[76,77,78],[77,78,79],[78,79,80],[79,80,81],[80,81,82],[81,82,83],[82,83,84],[83,84,85],[84,85,8" + + "6],[85,86,87],[86,87,88],[87,88,89],[88,89,90],[89,90,91],[90,91,92],[91,92,93],[92,93,94],[93,94,95],[94,95,96],[95,96,97],[96,97,98],[97,9" + + "8,99],[98,99,100],[99,100,101],[100,101,102],[101,102,103],[102,103,104],[103,104,105],[104,105,106],[105,106,107],[106,107,108],[107,108,10" + + "9],[108,109,110],[109,110,111],[110,111,112],[111,112,113]]}", + ), + Vector( + name = "packed zero ticks", + reach = 3.0, + required = 20, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"packed zero ticks\",\"unit\":0,\"mode\":\"points\",\"vertices\":[[3,0,0],[0,0,0]],\"colors\":[225,225],\"faces\":[],\"ti" + + "cks\":[-2]}", + ), + ) + + @Test + fun ourLadderMatchesTheReferenceImplementation() { + vectors.forEach { vector -> + val payload = SnoParser.parse(vector.json).payloadOrNull() + assertNotNull(payload, "${vector.name} did not parse") + assertEquals(vector.required, SnoAvatarWork.required(payload), "${vector.name} (reach ${vector.reach})") + } + } + + @Test + fun theWholeSetIsCovered() { + assertEquals(8, vectors.size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt new file mode 100644 index 0000000000..987c43670d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt @@ -0,0 +1,111 @@ +/* + * 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.assertTrue + +/** + * The work an avatar owes (`CYBERSPACE_V2.md` §8.10). + * + * The expected values are computed independently from the section's own + * normative pseudocode rather than lifted from a client, so this checks the + * Kotlin against the specification and not against another port of it. + */ +class SnoAvatarWorkTest { + private data class Vector( + val name: String, + val expectedBits: Int, + val json: String, + ) + + private val vectors = + listOf( + Vector( + name = "one gibson", + expectedBits = 16, + json = "{\"v\":2,\"name\":\"one gibson\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[0,1,0]],\"colors\":[225,225,225],\"faces\":[[0,1,2]]}", + ), + Vector( + name = "tetra at unit 0", + expectedBits = 18, + json = "{\"v\":2,\"name\":\"tetra at unit 0\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "tetra at unit 12", + expectedBits = 42, + json = "{\"v\":2,\"name\":\"tetra at unit 12\",\"unit\":12,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "tetra at unit 33", + expectedBits = 84, + json = "{\"v\":2,\"name\":\"tetra at unit 33\",\"unit\":33,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "sub-unit only", + expectedBits = 16, + json = "{\"v\":2,\"name\":\"sub-unit only\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[0,0,0],[0,0,0]],\"colors\":[225,225,225],\"faces\":[[0,1,2]],\"ticks\":[[0,0,0],[40,0,0],[0,60,0]]}", + ), + Vector( + name = "detailed", + expectedBits = 25, + json = "{\"v\":2,\"name\":\"detailed\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[2,0,0],[3,0,0],[4,0,0],[0,1,0],[1,1,0],[2,1,0],[3,1,0],[4,1,0],[0,2,0],[1,2,0],[2,2,0],[3,2,0],[4,2,0],[0,3,0],[1,3,0],[2,3,0],[3,3,0],[4,3,0],[0,4,0],[1,4,0],[2,4,0],[3,4,0],[4,4,0],[0,0,1],[1,0,1],[2,0,1],[3,0,1],[4,0,1],[0,1,1],[1,1,1],[2,1,1],[3,1,1],[4,1,1],[0,2,1],[1,2,1],[2,2,1],[3,2,1],[4,2,1],[0,3,1],[1,3,1],[2,3,1],[3,3,1],[4,3,1],[0,4,1],[1,4,1],[2,4,1],[3,4,1],[4,4,1],[0,0,2],[1,0,2],[2,0,2],[3,0,2],[4,0,2],[0,1,2],[1,1,2],[2,1,2],[3,1,2],[4,1,2]],\"colors\":[225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2]]}", + ), + ) + + @Test + fun theLadderMatchesTheSpecification() { + vectors.forEach { vector -> + val payload = SnoParser.parse(vector.json).payloadOrNull() + assertTrue(payload != null, "${vector.name} did not parse") + assertEquals(vector.expectedBits, SnoAvatarWork.required(payload), vector.name) + } + } + + @Test + fun theFloorIsSixteenBits() { + // "reach: ... never below one gibson", and detail below the free + // thirty-two contributes nothing, so the smallest avatar owes the floor. + val tiny = SnoParser.parse("""{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[225],"faces":[]}""").payloadOrNull()!! + assertEquals(SnoAvatarWork.FLOOR_BITS, SnoAvatarWork.required(tiny)) + } + + @Test + fun reachCostsTwoBitsPerDoubling() { + // The ladder §8.10 names: one gibson 16 bits, two 18, four 20, sixteen 24. + fun atUnit(unit: Int) = + SnoAvatarWork.required( + SnoParser.parse("""{"v":2,"name":"r","unit":$unit,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""").payloadOrNull()!!, + ) + assertEquals(16, atUnit(0), "one gibson") + assertEquals(18, atUnit(1), "two gibsons") + assertEquals(20, atUnit(2), "four gibsons") + assertEquals(24, atUnit(4), "sixteen gibsons") + } + + @Test + fun aSectorSizedAvatarIsOutOfReachOfAnyHashPower() { + // "one the size of cyberspace about 190, which is to say never." + val huge = SnoParser.parse("""{"v":2,"name":"vast","unit":84,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""").payloadOrNull()!! + assertTrue(SnoAvatarWork.required(huge) > 150, "a cyberspace-sized avatar must be unmineable") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt new file mode 100644 index 0000000000..fdccd1ea19 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt @@ -0,0 +1,64 @@ +/* + * 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 + +/** + * The built-in palette is normative (DECK-0003 Appendix C) and 256 entries long, + * generated here from `decks/sno-palette.json` rather than typed. These anchors + * are the ones the deck itself names, so a bad paste cannot ship silently. + */ +class SnoBuiltInPaletteTest { + @Test + fun theresTwoHundredAndFiftySixOfThem() { + assertEquals(256, SnoBuiltInPalette.COLORS.size) + assertEquals(256, SnoPalette.BUILT_IN.size) + } + + @Test + fun theAnchorsAppendixANamesAreWhereItSaysTheyAre() { + assertEquals(0xFFFF0000.toInt(), SnoBuiltInPalette.COLORS[238], "238 is pure red") + assertEquals(0xFF00FF00.toInt(), SnoBuiltInPalette.COLORS[235], "235 is pure green") + assertEquals(0xFF0000FF.toInt(), SnoBuiltInPalette.COLORS[239], "239 is pure blue") + assertEquals(0xFFFFFFFF.toInt(), SnoBuiltInPalette.COLORS[225], "225 is white") + assertEquals(0xFF000000.toInt(), SnoBuiltInPalette.COLORS[224], "224 is black") + } + + @Test + fun theRampsAndSteelsAreWhereTheLayoutSaysTheyAre() { + // 0..191 is 24 hues of 8 steps; the first entry is the darkest of hue 0. + assertEquals(0xFF003632.toInt(), SnoBuiltInPalette.COLORS[0]) + // 192..223 is 32 steels running black to white. + assertEquals(0xFF010101.toInt(), SnoBuiltInPalette.COLORS[192]) + assertEquals(0xFFF8F8F8.toInt(), SnoBuiltInPalette.COLORS[223]) + // 224..255 is the signatures; the last is a deep ground. + assertEquals(0xFF1C1C0F.toInt(), SnoBuiltInPalette.COLORS[255]) + } + + @Test + fun everyEntryIsOpaque() { + SnoBuiltInPalette.COLORS.forEach { + assertEquals(0xFF, (it shr 24) and 0xFF) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt new file mode 100644 index 0000000000..639350ff1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt @@ -0,0 +1,165 @@ +/* + * 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.assertTrue + +/** + * Every `kind 33331` object that existed on the network, read back. + * + * Collected 2026-09-21 from relay.damus.io and nos.lol (cyberspace.nostr1.com + * and relay.primal.net held none). Seven objects from six authors. + * + * Three of them are the legacy cohort DECK-0003 §5 describes: `v: 2` payloads + * whose `colors` are still literal `[r, g, b]` triples from before the palette + * change of 2026-09-16. `decks/sno-reference.py` refuses those three; `sno-core` + * v0.1.4 accepts them deliberately, and so do we. **All seven must read**, which + * is the parity target — a strict reader shows an error for nearly half of what + * exists. + */ +class SnoLiveFixtureTest { + private data class Fixture( + val id: String, + val name: String, + val vertices: Int, + val faces: Int, + val mode: SnoMode, + val unit: Int, + val legacyTriples: Boolean, + val json: String, + ) + + private val fixtures = + listOf( + Fixture( + id = "fbe6a776bde5b90d", + name = "hello world", + vertices = 4, + faces = 4, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"hello world\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[3,0,0],[1,0,-3],[1,3,-1]],\"ticks\":[-4],\"colors\":[[1,0.15,0.15],[0.15,1,0.3],[0.2,0.4,1],[1,1,0.6]],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Fixture( + id = "4b05663f46353d11", + name = "second thought", + vertices = 4, + faces = 4, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"second thought\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[3,0,0],[1,0,-3],[1,3,-1]],\"ticks\":[-4],\"colors\":[[1,0.15,0.15],[0.15,1,0.3],[0.2,0.4,1],[1,1,0.6]],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Fixture( + id = "04df8e12623fe071", + name = "Ship", + vertices = 18, + faces = 24, + mode = SnoMode.LINES, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"Ship\",\"unit\":0,\"extent\":8,\"mode\":\"lines\",\"vertices\":[[0,0,0],[-1,0,0],[0,0,-2],[0,0,1],[-1,0,0],[0,0,0],[0,0,0],[0,-1,0],[-1,-1,0],[1,-1,0],[-1,0,0],[-1,0,1],[-1,0,0],[0,0,1],[0,0,0],[0,0,0],[0,0,1],[-1,0,1]],\"ticks\":[[48,0,0],[72,0,0],[0,0,96],-1,[72,24,72],[48,24,72],[0,48,0],[0,96,0],[0,96,72],[0,96,72],[48,0,96],[96,0,0],[72,24,96],[24,0,0],[72,0,96],[48,24,96],[48,0,24],[72,0,24]],\"colors\":[[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.08627450980392157,0.0784313725490196,0.1411764705882353],[0.5882352941176471,0.14901960784313725,1],[0.5882352941176471,0.14901960784313725,1],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.08627450980392157,0.0784313725490196,0.1411764705882353],[0.5882352941176471,0.14901960784313725,1],[0.5882352941176471,0.14901960784313725,1],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.13725490196078433,0.13725490196078433],[1,0.13725490196078433,0.13725490196078433]],\"faces\":[[9,5,0],[4,1,8],[2,0,7],[1,2,7],[2,6,0],[1,6,2],[6,1,3],[3,0,6],[3,0,7],[3,1,7],[3,9,0],[3,1,8],[4,1,3],[5,0,3],[4,8,3],[5,3,9],[15,13,14],[11,12,10],[17,12,10],[12,11,17],[10,11,17],[15,13,16],[14,15,16],[14,16,13]]}", + ), + Fixture( + id = "89f510e1fc40fe85", + name = "Shard 4 copy", + vertices = 35, + faces = 20, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Shard 4 copy\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[4,0,0],[2,0,-2],[0,0,-2],[0,2,0],[0,5,0],[0,2,-2],[0,2,-2],[4,0,-4],[4,0,-5],[3,0,-5],[3,0,-6],[2,0,-6],[1,0,-6],[1,0,-5],[0,0,-5],[0,0,-4],[0,0,-3],[1,0,-3],[1,0,-2],[2,0,-2],[3,0,-2],[3,0,-3],[4,0,-3],[4,0,-4],[7,0,-4],[5,0,-5],[4,0,-8],[2,0,-6],[0,0,-6],[1,0,-4],[0,0,-2],[2,0,-2],[4,0,0],[5,0,-3]],\"ticks\":[-35],\"colors\":[246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246],\"faces\":[[0,1,2],[0,2,3],[4,7,6],[4,6,5],[0,4,5],[0,5,1],[3,2,6],[3,6,7],[0,3,7],[0,7,4],[1,5,6],[1,6,2],[34,25,26],[34,26,27],[34,27,28],[34,28,29],[34,29,30],[34,30,31],[34,31,32],[32,33,34]]}", + ), + Fixture( + id = "47031e0f64e233d6", + name = "Shard 2", + vertices = 5, + faces = 5, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Shard 2\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[-1,0,2],[3,0,2],[3,0,-2],[-1,0,-2],[1,4,0]],\"ticks\":[-5],\"colors\":[226,226,226,226,226],\"faces\":[[1,0,4],[2,1,4],[3,2,4],[0,1,2],[0,2,3]]}", + ), + Fixture( + id = "d12996088aa99127", + name = "first object", + vertices = 118, + faces = 94, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"first object\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-1,0,1],[3,0,1],[3,0,-3],[-1,0,-3],[1,4,-1],[-5,0,1],[-1,0,1],[-1,0,-3],[-5,0,-3],[-3,4,-1],[-5,0,5],[-1,0,5],[-1,0,1],[-5,0,1],[-3,4,3],[-1,0,2],[3,0,2],[3,0,-2],[-1,0,-2],[1,4,0],[-3,0,2],[1,0,2],[1,0,-2],[-3,0,-2],[-1,4,0],[-4,0,3],[0,0,3],[0,0,-1],[-4,0,-1],[-2,4,1],[-4,0,-2],[0,0,-2],[0,0,-6],[-4,0,-6],[-2,4,-4],[-8,0,1],[-6,0,1],[-6,0,-1],[-8,0,-1],[-8,2,1],[-8,2,-1],[-8,0,-2],[-6,0,-2],[-6,0,-4],[-8,0,-4],[-8,2,-2],[-8,2,-4],[-8,0,-4],[-6,0,-4],[-6,0,-6],[-8,0,-6],[-8,2,-4],[-8,2,-6],[-8,0,2],[-6,0,2],[-6,0,0],[-8,0,0],[-8,2,2],[-8,2,0],[2,0,7],[4,0,7],[4,0,5],[2,0,5],[2,2,7],[2,2,5],[-2,0,7],[0,0,7],[0,0,5],[-2,0,5],[-2,2,7],[-2,2,5]],\"ticks\":[-118],\"colors\":[226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226],\"faces\":[[43,42,46],[44,43,46],[45,44,46],[42,45,46],[42,43,44],[42,44,45],[48,47,51],[49,48,51],[50,49,51],[47,50,51],[47,48,49],[47,49,50],[53,52,56],[54,53,56],[55,54,56],[52,55,56],[52,53,54],[52,54,55],[58,57,61],[59,58,61],[60,59,61],[57,60,61],[57,58,59],[57,59,60],[63,62,66],[64,63,66],[65,64,66],[62,65,66],[62,63,64],[62,64,65],[68,67,71],[69,68,71],[70,69,71],[67,70,71],[67,68,69],[67,69,70],[73,72,76],[74,73,76],[75,74,76],[72,75,76],[72,73,74],[72,74,75],[78,77,81],[79,78,81],[80,79,81],[77,80,81],[77,78,79],[77,79,80],[82,83,84],[82,84,85],[82,85,87],[82,87,86],[83,86,87],[83,87,84],[83,82,86],[85,84,87],[88,89,90],[88,90,91],[88,91,93],[88,93,92],[89,92,93],[89,93,90],[89,88,92],[94,95,96],[94,96,97],[94,97,99],[94,99,98],[95,98,99],[95,99,96],[97,96,99],[100,101,102],[100,102,103],[100,103,105],[100,105,104],[101,104,105],[101,105,102],[101,100,104],[103,102,105],[106,107,108],[106,108,109],[106,109,111],[106,111,110],[107,110,111],[107,111,108],[107,106,110],[109,108,111],[112,113,114],[112,114,115],[112,115,117],[112,117,116],[113,116,117],[113,117,114],[113,112,116],[115,114,117]]}", + ), + Fixture( + id = "8fe392c2fd93d6a7", + name = "Triforce", + vertices = 12, + faces = 18, + mode = SnoMode.SOLID, + unit = 12, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Triforce\",\"unit\":12,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,2,0],[-1,0,0],[1,0,0],[-2,-1,0],[0,-1,0],[2,-1,0],[0,2,-1],[-1,0,-1],[1,0,-1],[-2,-1,-1],[0,-1,-1],[2,-1,-1]],\"ticks\":[[0,40,0],[0,80,0],[0,80,0],-3,[0,40,80],[0,80,80],[0,80,80],[0,0,80],[0,0,80],[0,0,80]],\"colors\":[249,249,249,249,249,249,249,249,249,249,249,249],\"faces\":[[1,0,2],[3,1,4],[2,4,5],[7,6,8],[9,7,10],[8,10,11],[5,0,6],[6,11,5],[9,11,5],[5,3,9],[9,6,0],[0,3,9],[10,7,1],[1,4,10],[4,2,8],[8,10,4],[1,2,8],[8,7,1]]}", + ), + ) + + @Test + fun everyObjectOnTheNetworkReads() { + fixtures.forEach { fixture -> + val result = SnoParser.parse(fixture.json) + assertTrue(result is SnoResult.Invalid == false, "${fixture.id} (${fixture.name}) was refused: $result") + val payload = (result as SnoResult.Valid).payload + assertEquals(fixture.name, payload.name, fixture.id) + assertEquals(fixture.vertices, payload.vertexCount, fixture.id) + assertEquals(fixture.faces, payload.faceCount, fixture.id) + assertEquals(fixture.mode, payload.mode, fixture.id) + assertEquals(fixture.unit, payload.unit, fixture.id) + } + } + + @Test + fun theLegacyCohortIsThreeOfSeven() { + assertEquals(7, fixtures.size) + assertEquals(3, fixtures.count { it.legacyTriples }) + } + + @Test + fun everyColourIsOpaque() { + fixtures.forEach { fixture -> + val payload = SnoParser.parse(fixture.json).payloadOrNull()!! + payload.colors.forEach { argb -> + assertEquals(0xFF, (argb shr 24) and 0xFF, "${fixture.id} has a transparent colour") + } + } + } + + @Test + fun everyFaceIndexIsInRange() { + fixtures.forEach { fixture -> + val payload = SnoParser.parse(fixture.json).payloadOrNull()!! + payload.faces.forEach { index -> + assertTrue(index >= 0 && index < payload.vertexCount, "${fixture.id} has a face index out of range") + } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt new file mode 100644 index 0000000000..bced983477 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt @@ -0,0 +1,133 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Reading a palette out of the event an object's reference named (§1.3b). + * + * The `c`-tag shapes here are taken from real `kind 3367` colour moments + * sampled off relay.damus.io and nos.lol: four to six `#rrggbb` values in + * document order, an emoji in `content`, and `layout` / `alt` / `client` / + * `name` alongside. A survey of five relays found 205 such events from 51 + * pubkeys and every one of them is shaped this way, so the tag form is not a + * hypothetical. + */ +class SnoPaletteEventReaderTest { + private fun event( + tags: Array>, + content: String = "", + kind: Int = 3367, + ) = Event("00".repeat(32), "11".repeat(32), 0L, kind, tags, content, "22".repeat(64)) + + @Test + fun theCTagsAreThePaletteAndTheirOrderIsTheIndex() { + val palette = + SnoPaletteEventReader.read( + event( + arrayOf( + arrayOf("name", "dusk"), + arrayOf("c", "#93A8D7"), + arrayOf("c", "#49413A"), + arrayOf("c", "#534E46"), + arrayOf("c", "#D2D1BF"), + arrayOf("alt", "a colour moment"), + arrayOf("client", "somewhere"), + ), + content = "\uD83C\uDF05", + ), + ) + + assertNotNull(palette) + assertEquals(4, palette.size) + assertEquals(0xFF93A8D7.toInt(), palette[0]) + assertEquals(0xFF49413A.toInt(), palette[1]) + assertEquals(0xFFD2D1BF.toInt(), palette[3]) + } + + @Test + fun lowercaseHexReadsTheSame() { + val palette = SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")))) + assertNotNull(palette) + assertEquals(0xFFFF0000.toInt(), palette[0]) + } + + @Test + fun aMalformedColourDisqualifiesTheTagForm() { + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "ff0000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#gg0000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff000000"), arrayOf("c", "#00ff00"))))) + } + + @Test + fun oneColourIsNotAPalette() { + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"))))) + } + + @Test + fun theContentFormIsAcceptedAsLegacy() { + // Accepted only because an earlier draft of §1.3b described it; nothing + // should write it now, and no event on the network does. + val triples = SnoPaletteEventReader.read(event(arrayOf(), content = "[[255,0,0],[0,255,0]]")) + assertNotNull(triples) + assertEquals(0xFFFF0000.toInt(), triples[0]) + + val strings = SnoPaletteEventReader.read(event(arrayOf(), content = """["#ff0000","#00ff00"]""")) + assertNotNull(strings) + assertEquals(0xFF00FF00.toInt(), strings[1]) + } + + @Test + fun theTagsWinOverTheContent() { + val palette = + SnoPaletteEventReader.read( + event( + arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")), + content = "[[0,0,255],[0,0,255],[0,0,255]]", + ), + ) + assertNotNull(palette) + assertEquals(2, palette.size) + assertEquals(0xFFFF0000.toInt(), palette[0]) + } + + @Test + fun anEventThatIsNotAPaletteReadsAsNothing() { + // Which counts as a failed fetch, which means the built-in — never a + // refusal to draw the object that named it. + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("p", "ff")), content = "hello"))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(), content = ""))) + } + + @Test + fun theKindIsNotConstrained() { + // "This format does not define a palette kind and does not want one." + val palette = SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")), kind = 1)) + assertNotNull(palette) + assertEquals(2, palette.size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt new file mode 100644 index 0000000000..c6369615ec --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt @@ -0,0 +1,344 @@ +/* + * 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 + +/** + * What DECK-0003 says a reader must *accept*, which the rejection table cannot + * cover: the worked example, the exactness the lattice promises, the two places + * the format repairs rather than refuses, and the run-length encodings. + */ +class SnoParserAcceptanceTest { + private val appendixA = + """ + {"v":2,"name":"tetra","unit":0,"extent":8,"mode":"solid", + "vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]], + "ticks":[-4], + "colors":[238,235,239,225], + "faces":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]} + """.trimIndent() + + @Test + fun appendixAReads() { + val payload = SnoParser.parse(appendixA).payloadOrNull() + assertNotNull(payload) + assertEquals("tetra", payload.name) + assertEquals(4, payload.vertexCount) + assertEquals(4, payload.faceCount) + assertEquals(SnoMode.SOLID, payload.mode) + assertEquals(0, payload.unit) + assertEquals(8, payload.extent) + + // The built-in palette names these: pure red, green, blue, white. + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + assertEquals(0xFF00FF00.toInt(), payload.colors[1]) + assertEquals(0xFF0000FF.toInt(), payload.colors[2]) + assertEquals(0xFFFFFFFF.toInt(), payload.colors[3]) + + // The fourth vertex is at (1, 2, 1) whole units, exactly. + assertEquals(120, payload.tickAt(3, 0)) + assertEquals(240, payload.tickAt(3, 1)) + assertEquals(120, payload.tickAt(3, 2)) + } + + @Test + fun fortyTicksIsExactlyOneThirdOfAUnit() { + // The whole reason positions are integers: a third of a unit lands on the + // lattice and stays there. Three of them make exactly one unit, which is + // a thing no float format can promise. + val payload = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", ticks = ""","ticks":[[40,0,0],[40,0,0],[40,0,0],-1]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(40, payload.tickAt(0, 0)) + assertEquals(SnoPayload.TICKS_PER_UNIT, payload.tickAt(0, 0) * 3) + } + + @Test + fun absentTicksMeansEveryPositionIsWhole() { + val withRun = SnoParser.parse(appendixA).payloadOrNull()!! + val without = SnoParser.parse(payload(ticks = "")).payloadOrNull()!! + assertTrue(withRun.positions.contentEquals(without.positions), "[-4] and an absent ticks array must mean the same thing") + } + + @Test + fun extentIsRepairedAndThenGrown() { + // Out of range becomes the default of 8... + assertEquals(8, SnoParser.parse(payload(extent = ""","extent":0""")).payloadOrNull()!!.extent) + assertEquals(8, SnoParser.parse(payload(extent = ""","extent":999""")).payloadOrNull()!!.extent) + assertEquals(8, SnoParser.parse(payload(extent = "")).payloadOrNull()!!.extent) + + // ...and then grows until it contains every vertex. The data wins and the + // hint is corrected; the object is never refused for disagreeing with it. + val wide = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[9,2,1]]", extent = ""","extent":8""")).payloadOrNull() + assertNotNull(wide) + assertEquals(9, wide.extent) + } + + @Test + fun aVertexPastOneUnitGrowsTheExtentToTheNextWholeUnit() { + val justOver = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[8,2,1]]", ticks = ""","ticks":[-3,[1,0,0]]""")).payloadOrNull() + assertNotNull(justOver) + assertEquals(9, justOver.extent, "8 units and one tick needs a grid of 9") + } + + @Test + fun faceColoursRunLengthDecode() { + val flat = SnoParser.parse(payload(extra = ""","facecolors":[238,-3]""")).payloadOrNull() + assertNotNull(flat) + val faceColors = flat.faceColors + assertNotNull(faceColors) + assertEquals(4, faceColors.size) + faceColors.forEach { assertEquals(0xFFFF0000.toInt(), it) } + } + + @Test + fun absentFaceColoursIsNotColourZero() { + assertNull(SnoParser.parse(appendixA).payloadOrNull()!!.faceColors) + } + + @Test + fun vertexColoursSurviveFaceColours() { + // §1.4a: vertex colours are untouched and still colour the points and the + // lines, so an object may carry both without contradiction. + val both = SnoParser.parse(payload(extra = ""","facecolors":[238,-3]""")).payloadOrNull()!! + assertEquals(0xFF00FF00.toInt(), both.colors[1]) + } + + @Test + fun versionOneNegatesZAndVersionTwoDoesNot() { + val v2 = SnoParser.parse(payload(version = 2)).payloadOrNull()!! + val v1 = SnoParser.parse(payload(version = 1, colors = """[[1,0,0],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull()!! + + // Vertex 2 is at z = 2 units as written. + assertEquals(240, v2.tickAt(2, 2)) + assertEquals(-240, v1.tickAt(2, 2)) + // X and Y are untouched by the flip. + assertEquals(v2.tickAt(2, 0), v1.tickAt(2, 0)) + assertEquals(v2.tickAt(2, 1), v1.tickAt(2, 1)) + } + + @Test + fun aVersionTwoPayloadCarryingLiteralTriplesStillReads() { + // The one place this reader is knowingly more forgiving than + // sno-reference.py, and it matches sno-core. See SnoParser.readLiteralTriple. + val payload = SnoParser.parse(payload(colors = """[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF, (payload.colors[0] shr 16) and 0xFF) + assertEquals(38, (payload.colors[0] shr 8) and 0xFF, "0.15 of 255, rounded") + + // ...and it keeps the v2 frame. Flipping it as though it were v1 would + // mirror the object. + assertEquals(240, payload.tickAt(2, 2)) + } + + @Test + fun aVersionOnePayloadWithAnIndexIsRefused() { + // Version 1 never had an index, so this is not a colour it can read. + val result = SnoParser.parse(payload(version = 1)) + assertTrue(result is SnoResult.Invalid) + assertEquals("8b", result.rule) + } + + @Test + fun literalTriplesAreClampedNotRejected() { + val payload = SnoParser.parse(payload(colors = """[[2,-1,0.5],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF, (payload.colors[0] shr 16) and 0xFF) + assertEquals(0x00, (payload.colors[0] shr 8) and 0xFF) + } + + @Test + fun aStrayTypeFieldIsIgnored() { + // §1.1a: rejecting on noise would refuse every object already published, + // and three of the seven on the network carry this field. + val payload = SnoParser.parse(payload(extra = ""","type":"shard"""")).payloadOrNull() + assertNotNull(payload) + assertEquals("tetra", payload.name) + } + + @Test + fun unknownFieldsAreIgnored() { + // §5: what allows an optional field to be added without a version bump. + assertNotNull(SnoParser.parse(payload(extra = ""","emissive":true,"whatever":[1,2,3]""")).payloadOrNull()) + } + + @Test + fun nameIsTruncatedNotRejected() { + val long = "x".repeat(200) + val payload = SnoParser.parse(payload(name = long)).payloadOrNull() + assertNotNull(payload) + assertEquals(SnoPayload.MAX_NAME, payload.name.length) + } + + @Test + fun aMissingNameIsToleratedBecauseNothingEnforcesIt() { + // §1.1's table calls `name` required and neither reference implementation + // checks it: sno-reference.py has no test at all and sno-core falls back + // to a default. Refusing here would be stricter than everything that + // exists, over a field that is decoration. + val payload = SnoParser.parse("""{"v":2,"unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[0],"faces":[]}""").payloadOrNull() + assertNotNull(payload) + assertEquals("", payload.name) + } + + @Test + fun anEmptyFaceListIsAPointCloud() { + val payload = SnoParser.parse("""{"v":2,"name":"dust","unit":0,"mode":"points","vertices":[[0,0,0],[1,1,1]],"colors":[0,1],"faces":[]}""").payloadOrNull() + assertNotNull(payload) + assertEquals(0, payload.faceCount) + assertEquals(2, payload.vertexCount) + } + + @Test + fun spinIsIgnoredWithoutUpButStillValidated() { + // §1.7: a reader MUST ignore the value when `up` is not true, and MUST + // reject a spin out of range whether or not `up` is present. + val ignored = SnoParser.parse(payload(extra = ""","spin":90""")).payloadOrNull() + assertNotNull(ignored) + assertEquals(0, ignored.spin) + assertTrue(!ignored.up) + + val standing = SnoParser.parse(payload(extra = ""","up":true,"spin":90""")).payloadOrNull() + assertNotNull(standing) + assertEquals(90, standing.spin) + assertTrue(standing.up) + } + + @Test + fun upFalseIsTheSameAsAbsent() { + val payload = SnoParser.parse(payload(extra = ""","up":false,"spin":90""")).payloadOrNull() + assertNotNull(payload) + assertTrue(!payload.up) + assertEquals(0, payload.spin) + } + + @Test + fun anInlinePaletteIsUsed() { + val payload = + SnoParser + .parse(payload(colors = "[0,1,2,3]", extra = ""","palette":[[255,0,0],[0,255,0],[0,0,255],[255,255,255]]""")) + .payloadOrNull() + assertNotNull(payload) + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + assertTrue(payload.paletteRef is SnoPaletteRef.Inline) + } + + @Test + fun theRegisteredNameIsTheBuiltIn() { + val payload = SnoParser.parse(payload(extra = ""","palette":"cyberspace-neon-256"""")).payloadOrNull() + assertNotNull(payload) + assertEquals(SnoPaletteRef.BuiltIn, payload.paletteRef) + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + } + + @Test + fun anUnknownPaletteNameIsRefused() { + val result = SnoParser.parse(payload(extra = ""","palette":"someone-elses-256"""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("8a", result.rule) + } + + @Test + fun anUnresolvedPaletteReferenceDrawsInTheBuiltIn() { + // §1.3b: a reference is never load-bearing. The worst case is an object + // drawn in the wrong colours, never one that cannot be drawn. + val reference = "nevent1qqsglcujct7e84485kkv4sdtsyfg9nszefsc7s40r4ss07a9nqpzzhghup94y" + val result = SnoParser.parse(payload(extra = ""","palette":"$reference"""")) + val payload = result.payloadOrNull() + assertNotNull(payload, "an unresolvable reference must never refuse the object: $result") + assertEquals(0xFFFF0000.toInt(), payload.colors[0], "indices name the built-in until the event is in hand") + assertTrue(payload.paletteRef is SnoPaletteRef.Event) + } + + @Test + fun aFetchedPaletteReplacesTheBuiltIn() { + val reference = "nevent1qqsglcujct7e84485kkv4sdtsyfg9nszefsc7s40r4ss07a9nqpzzhghup94y" + val fetched = SnoPalette(IntArray(256) { 0xFF123456.toInt() }) + val payload = SnoParser.parse(payload(extra = ""","palette":"$reference""""), fetched).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF123456.toInt(), payload.colors[0]) + } + + @Test + fun anNaddrReferenceIsAlsoWellFormed() { + // §1.3a accepts an naddr for a palette somebody publishes as an + // addressable event of their own, though what an naddr cannot do is name + // a kind 3367, which is where the palettes actually are. + val naddr = "naddr1qqyhqctvv468gefdxypzp68dx7vvdlltll5w6duccml7hllga5me33hla0l73mfhnrr0l6llqvzqqqqdyucnkls9" + val payload = SnoParser.parse(payload(extra = ""","palette":"$naddr"""")).payloadOrNull() + assertNotNull(payload) + assertTrue(payload.paletteRef is SnoPaletteRef.Event) + } + + @Test + fun aVertexBeyondTheGridGrowsItRatherThanFailing() { + // §1.8 puts the position bound on publishers — a vertex further than 64 + // model units from the origin is theirs not to write — and gives a + // reader the choice: "A reader MAY reject such a payload and MAY + // instead repair it by growing the extent." Both references repair, so + // this does too, and the repair is visible in the extent. + val payload = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]")).payloadOrNull() + assertNotNull(payload) + assertEquals(65, payload.extent, "the extent should have grown past MAX_EXTENT to hold the vertex") + assertEquals(65 * SnoPayload.TICKS_PER_UNIT, payload.tickAt(3, 0), "and the coordinate should be untouched") + } + + @Test + fun aVertexExactlyAtTheBoundIsAccepted() { + assertNotNull(SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[64,2,1]]")).payloadOrNull()) + } + + @Test + fun aVertexPastWhatTheLatticeHoldsIsStillRefused() { + // The one bound left is the lattice's own: a position is a total tick + // count in an Int, and past Int.MAX_VALUE ticks there is no coordinate + // to repair, only one that would wrap. + val justInside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895697,2,1]]")).payloadOrNull() + assertNotNull(justInside) + assertEquals(17895697 * SnoPayload.TICKS_PER_UNIT, justInside.tickAt(3, 0)) + + val justOutside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]]")) + assertTrue(justOutside is SnoResult.Invalid) + assertEquals("bound", justOutside.rule) + } + + @Test + fun aWholeTooLargeForALongIsRefusedRatherThanRounded() { + // A JSON integer has no width. One past a Long would come back as a + // Double through a lazier reader and land somewhere near the origin. + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[99999999999999999999,2,1]]")) + assertTrue(result is SnoResult.Invalid) + } + + private fun payload( + version: Int = 2, + name: String = "tetra", + vertices: String = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", + colors: String = "[238,235,239,225]", + ticks: String = ""","ticks":[-4]""", + extent: String = ""","extent":8""", + extra: String = "", + ) = """{"v":$version,"name":"$name","unit":0$extent,"mode":"solid","vertices":$vertices$ticks,"colors":$colors,"faces":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]$extra}""" +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt new file mode 100644 index 0000000000..2767b96bc6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt @@ -0,0 +1,108 @@ +/* + * 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.assertTrue + +/** + * Integer overflow in the run-length encodings and the position bound. + * + * Both of §1's run-length forms say "a **negative** integer -N", and both were + * read by negating the entry. `-Int.MIN_VALUE` is `Int.MIN_VALUE` — still + * negative — so the guards that assumed a positive count did not hold, and + * `abs()` on the position bound had the same hole. + * + * These payloads reach the parser straight off a relay and the parse runs + * inside composition, so the ticks case was an uncaught crash in a feed from a + * hostile kind 33331, 3330 or 11333 event, and the other two were silent + * corruption. Every case here is a payload a stranger can publish. + */ +class SnoParserOverflowTest { + private fun payload( + vertices: String = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", + colors: String = "[238,235,239,225]", + ticks: String = "", + extra: String = "", + ) = """{"v":2,"name":"t","unit":0,"mode":"solid","vertices":$vertices$ticks,"colors":$colors,"faces":[[0,1,2]]$extra}""" + + @Test + fun aTicksRunOfIntMinValueIsRefusedRatherThanCrashing() { + // Negating this gives itself, so `written` went negative and the next + // triple indexed out of the buffer. + val result = SnoParser.parse(payload(ticks = ""","ticks":[-2147483648,[1,2,3]]""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("7", result.rule) + } + + @Test + fun aTicksRunLongerThanTheVertexListIsRefused() { + assertTrue(SnoParser.parse(payload(ticks = ""","ticks":[-5]""")) is SnoResult.Invalid) + assertTrue(SnoParser.parse(payload(ticks = ""","ticks":[-2147483647]""")) is SnoResult.Invalid) + } + + @Test + fun aFaceColourRunOfIntMinValueIsRefused() { + // This one parsed Valid: the run was swallowed whole and the expansion + // never reached the length check. + val result = SnoParser.parse(payload(extra = ""","facecolors":[0,-2147483648]""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("8c", result.rule) + } + + @Test + fun aVertexOfIntMinValueIsRefusedRatherThanRewritten() { + // abs(Int.MIN_VALUE) is negative, so the ±64-unit bound passed; then + // `* 120` overflowed to exactly 0 and the payload parsed Valid with the + // coordinate silently moved to the origin. + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[-2147483648,2,1]]")) + assertTrue(result is SnoResult.Invalid) + assertEquals("bound", result.rule) + } + + @Test + fun aVertexOfIntMaxValueIsRefused() { + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[2147483647,2,1]]")) + assertTrue(result is SnoResult.Invalid) + assertEquals("bound", result.rule) + } + + @Test + fun theOrdinaryRunLengthFormsStillWork() { + // The guards must not have cost the encodings they protect. + val whole = SnoParser.parse(payload(ticks = ""","ticks":[-4]""")).payloadOrNull() + assertNotNull(whole) + assertEquals(0, whole.tickAt(0, 0)) + + val flat = SnoParser.parse(payload(extra = ""","facecolors":[238]""")).payloadOrNull() + assertNotNull(flat) + assertEquals(1, flat.faceColors?.size) + + val mixed = SnoParser.parse(payload(ticks = ""","ticks":[[40,0,0],-3]""")).payloadOrNull() + assertNotNull(mixed) + // tickAt is the TOTAL: vertex 0 is [0,0,0] plus a 40-tick remainder, + // and vertex 1 is [2,0,0] with the run's zero remainder, so 2 units. + assertEquals(40, mixed.tickAt(0, 0)) + assertEquals(2 * SnoPayload.TICKS_PER_UNIT, mixed.tickAt(1, 0)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt new file mode 100644 index 0000000000..9b5d214092 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.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.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * DECK-0003 §1.9, one rejection per numbered rule. + * + * This table is `_rejections()` from `decks/sno-reference.py` in the cyberspace + * repository, transcribed mechanically rather than by hand: each case is a + * one-field mutation of the Appendix A tetrahedron, and each was confirmed to + * fail the reference validator on the rule it claims before being written here. + * A port that passes this passes what the deck's own arbiter enforces. + */ +class SnoParserRejectionTest { + private data class Case( + val rule: String, + val json: String, + ) + + private val cases = + listOf( + Case("1", "{\"v\":3,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("1", "{\"v\":0,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("2", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case( + "3", + "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],\"ticks\":[-513],\"colors\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"faces\":[]}", + ), + Case("4", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"wireframe\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("5", "{\"v\":2,\"name\":\"tetra\",\"unit\":85,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("5", "{\"v\":2,\"name\":\"tetra\",\"unit\":1.5,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("6", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1.5]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-3],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[[0,0,120],-3],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,9]]}"), + Case("8", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,1]]}"), + Case("8a", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"palette\":[[255,0,0]]}"), + Case("8a", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"palette\":[[255,0,0,0],[0,0,0]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,256],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,-1],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,1.5],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238,-4]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[-4]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238,256,-2]}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"up\":\"yes\"}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"up\":true,\"spin\":360}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"spin\":360}"), + ) + + @Test + fun everyNumberedRuleRejects() { + cases.forEach { case -> + val result = SnoParser.parse(case.json) + assertTrue(result is SnoResult.Invalid, "rule ${case.rule} should have been refused: ${case.json}") + assertEquals(case.rule, result.rule, "wrong rule blamed for ${case.json} (said: ${result.reason})") + } + } + + @Test + fun theTableCoversEveryRule() { + assertEquals( + setOf("1", "2", "3", "4", "5", "6", "7", "8", "8a", "8b", "8c", "10"), + cases.map { it.rule }.toSet(), + ) + assertEquals(25, cases.size) + } + + @Test + fun contentThatIsNotAJsonObjectIsRefused() { + listOf("", "not json", "[]", "null", "12").forEach { + assertTrue(SnoParser.parse(it) is SnoResult.Invalid, "should have been refused: $it") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt new file mode 100644 index 0000000000..65d4d4fb7b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt @@ -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 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> = 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 anEmptyShardIsSealedRatherThanBroken() { + // §7.6: a reader that cannot open a bag has learned nothing about the + // bag, so an item without its payload is not an error to report. A + // client draws nothing for one instead of complaining at its finder. + assertTrue(shard().isSealed()) + assertTrue(!shard(content = payloadJson).isSealed()) + + // It still has no shape to hand back, which is a separate question. + 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()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt index 34fe7fa03b..fa500dc60a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt new file mode 100644 index 0000000000..77099599bc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt @@ -0,0 +1,79 @@ +/* + * 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.utils.bigint + +import java.math.BigInteger + +/** + * [UBigInt] over `java.math.BigInteger`, which is what makes a region key + * affordable on the two platforms Amethyst actually ships to. + * + * A wrapper rather than a `typealias` for one reason: [toMinimalBytes]. The + * reference hashes `int_to_bytes_be_min`, and `BigInteger.toByteArray()` is + * two's complement, so it prepends a `0x00` sign byte whenever the top bit is + * set. Aliasing would put that byte into a SHA-256 for one number in two and + * produce a key nobody else derives. The allocation per operation is nothing + * against multiplications of megabyte operands. + */ +actual class UBigInt internal constructor( + internal val raw: BigInteger, +) : Comparable { + actual val bitLength: Int get() = raw.bitLength() + + actual val isZero: Boolean get() = raw.signum() == 0 + + actual operator fun plus(other: UBigInt): UBigInt = UBigInt(raw.add(other.raw)) + + actual operator fun times(other: UBigInt): UBigInt = UBigInt(raw.multiply(other.raw)) + + actual fun shiftRight(bits: Int): UBigInt { + require(bits >= 0) { "shift must not be negative" } + return UBigInt(raw.shiftRight(bits)) + } + + actual fun toMinimalBytes(): ByteArray { + if (raw.signum() == 0) return byteArrayOf(0) + val bytes = raw.toByteArray() + // Two's complement grows a leading zero exactly when the magnitude's + // top bit is set; the reference never writes one. + return if (bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + } + + actual override fun compareTo(other: UBigInt): Int = raw.compareTo(other.raw) + + actual override fun equals(other: Any?): Boolean = other is UBigInt && raw == other.raw + + actual override fun hashCode(): Int = raw.hashCode() + + override fun toString(): String = "UBigInt($bitLength bits)" + + actual companion object { + actual val ZERO: UBigInt = UBigInt(BigInteger.ZERO) + actual val ONE: UBigInt = UBigInt(BigInteger.ONE) + + actual fun of(value: Long): UBigInt { + require(value >= 0) { "negative values have no place on this lattice" } + return UBigInt(BigInteger.valueOf(value)) + } + + actual fun ofBytes(bytes: ByteArray): UBigInt = if (bytes.isEmpty()) ZERO else UBigInt(BigInteger(1, bytes)) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt index 19e3c0c7e2..1c8822924f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt @@ -114,4 +114,21 @@ class Ed25519Test { ) { kotlin.test.assertContentEquals(expected, actual) } + + @Test + fun keyPairFromSeedMatchesRfc8032TestVector1() { + val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60".hexToByteArray() + val kp = Ed25519.keyPairFromSeed(seed) + assertContentEquals("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".hexToByteArray(), kp.publicKey) + assertContentEquals(seed + kp.publicKey, kp.privateKey) + } + + @Test + fun keyPairFromSeedSignsLikeTheGeneratedPair() { + val generated = Ed25519.generateKeyPair() + val rebuilt = Ed25519.keyPairFromSeed(generated.privateKey.copyOfRange(0, 32)) + assertEquals(generated, rebuilt) + val msg = "hi".encodeToByteArray() + assertTrue(Ed25519.verify(msg, Ed25519.sign(msg, rebuilt.privateKey), generated.publicKey)) + } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt new file mode 100644 index 0000000000..7a3385da12 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt @@ -0,0 +1,73 @@ +/* + * 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.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** `authenticated_data` on application messages (RFC 9420 §6.3.2): sent in the clear, bound by the AEAD. */ +class AuthenticatedDataTest { + private val aad = "sender-binding".encodeToByteArray() + + @Test + fun theReceiverGetsTheSendersAuthenticatedData() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray(), aad)) + + assertContentEquals("hi".encodeToByteArray(), received.content) + assertContentEquals(aad, received.authenticatedData) + } + + @Test + fun withoutItTheAuthenticatedDataIsEmpty() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray())) + + assertEquals(0, received.authenticatedData.size) + } + + @Test + fun alteredAuthenticatedDataFailsToDecrypt() { + val (alice, bob) = twoMemberGroup() + + val sent = MlsMessage.decodeTls(TlsReader(alice.encrypt("hi".encodeToByteArray(), aad))) + val message = PrivateMessage.decodeTls(TlsReader(sent.payload)) + val altered = MlsMessage.fromPrivateMessage(message.copy(authenticatedData = "someone-else".encodeToByteArray())) + + assertFails { bob.decrypt(altered.toTlsBytes()) } + } + + private fun twoMemberGroup(): Pair { + val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + return alice to bob + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt new file mode 100644 index 0000000000..ada9e0375c --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt @@ -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.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities +import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. A GroupContextExtensions + * proposal can install a type this implementation doesn't know, as long as every member's leaf advertises it. + */ +class GroupContextExtensionsSupportTest { + // RFC 9420 §17.3 reserves 0xF000-0xFFFF for private use. + private val customType = 0xF0A1 + private val customExtension = Extension(customType, byteArrayOf(1, 2, 3)) + + // The Marmot leaf capabilities, with and without the custom type. + private val plain = Capabilities(extensions = listOf(0xF2EE), proposals = listOf(0x000A)) + private val withCustom = plain.copy(extensions = plain.extensions + customType) + + @Test + fun anExtensionEveryMemberSupportsIsInstalled() { + val (alice, bob) = twoMemberGroup(bobCapabilities = withCustom) + + alice.proposeGroupContextExtensions(alice.groupContextExtensionsSnapshot() + customExtension) + val commit = alice.commit() + bob.processFramedCommit(commit.framedCommitBytes) + + assertEquals(alice.epoch, bob.epoch) + for (group in listOf(alice, bob)) { + val installed = group.groupContextExtensionsSnapshot().single { it.extensionType == customType } + assertContentEquals(customExtension.extensionData, installed.extensionData) + } + } + + @Test + fun anExtensionSomeMemberDoesNotSupportIsRejected() { + val (alice, _) = twoMemberGroup(bobCapabilities = plain) + + alice.proposeGroupContextExtensions(alice.groupContextExtensionsSnapshot() + customExtension) + assertFails { alice.commit() } + } + + private fun twoMemberGroup(bobCapabilities: Capabilities): Pair { + val alice = MlsGroup.create("11".repeat(32).hexToByteArray(), capabilities = withCustom) + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0), capabilities = bobCapabilities) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + return alice to bob + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt new file mode 100644 index 0000000000..4e71e5f0c4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt @@ -0,0 +1,53 @@ +/* + * 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.marmot.mls.group + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GroupCreateOptionsTest { + private val creator = "11".repeat(32).hexToByteArray() + + @Test + fun aCallerChosenGroupIdIsUsed() { + val groupId = "my-group".encodeToByteArray() + val alice = MlsGroup.create(creator, groupId = groupId) + assertContentEquals(groupId, alice.groupId) + + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + assertContentEquals(groupId, bob.groupId) + } + + @Test + fun aGroupWithoutRequiredCapabilitiesWorks() { + val alice = MlsGroup.create(creator, requiredCapabilities = null) + assertTrue(alice.groupContextExtensionsSnapshot().none { it.extensionType == 0x0003 }) + + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + assertEquals(alice.epoch, bob.epoch) + assertContentEquals("hi".encodeToByteArray(), bob.decrypt(alice.encrypt("hi".encodeToByteArray())).content) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt new file mode 100644 index 0000000000..89472471a3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt @@ -0,0 +1,58 @@ +/* + * 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.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KeyPackageLifetimeTest { + private val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + private val bob = "22".repeat(32).hexToByteArray() + + @Test + fun theDefaultIsTheBoundedMarmotWindow() { + val lifetime = + assertNotNull( + alice + .createKeyPackage(bob, ByteArray(0)) + .keyPackage.leafNode.lifetime, + ) + assertTrue(lifetime.notAfter - lifetime.notBefore <= 84L * 24 * 3600 + 3600) + } + + @Test + fun aCallerChosenLifetimeIsUsedAndTheMemberCanBeAdded() { + val now = TimeUtils.now() + val chosen = Lifetime(now - 3600, now + 365L * 24 * 3600) + + val bundle = alice.createKeyPackage(bob, ByteArray(0), lifetime = chosen) + assertEquals(chosen, bundle.keyPackage.leafNode.lifetime) + + val result = alice.addMember(bundle.keyPackage.toTlsBytes()) + val joined = MlsGroup.processWelcome(result.welcomeBytes!!, bundle) + assertEquals(alice.epoch, joined.epoch) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt new file mode 100644 index 0000000000..3f0e032cd2 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt @@ -0,0 +1,122 @@ +/* + * 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 + +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a region key costs, and what writing the arithmetic cost against + * aliasing Java's. + * + * §7's whole feasibility argument is a number, and the plan's budget model — + * what a §7.7 sweep may be offered without asking — is read straight off it. A + * change that quietly makes a key five times slower does not break a test + * anywhere else; it makes a card lie about how long a search will take. So the + * number lives here. + * + * The comparison against `java.math.BigInteger` is the price of portability. + * [UBigInt] exists because a `expect`/`actual` would need a hand-written Apple + * and Linux implementation regardless, and two implementations of a consensus + * value is two chances to disagree — but Java's Toom-Cook is real and this + * measures how much of it was given up. + */ +class CantorTreeBenchmark { + private fun javaPair( + a: BigInteger, + b: BigInteger, + ): BigInteger { + val s = a.add(b) + return s.multiply(s.add(BigInteger.ONE)).shiftRight(1).add(b) + } + + private fun javaRoot( + base: BigInteger, + height: Int, + ): BigInteger { + if (height == 0) return base + val values = arrayOfNulls(height + 2) + val levels = IntArray(height + 2) + var top = 0 + for (i in 0 until (1L shl height)) { + var v = base.add(BigInteger.valueOf(i)) + var lvl = 0 + while (top > 0 && levels[top - 1] == lvl) { + top-- + v = javaPair(values[top]!!, v) + lvl++ + } + values[top] = v + levels[top] = lvl + top++ + } + return values[0]!! + } + + private fun millis(block: () -> Unit): Double { + val start = System.nanoTime() + block() + return (System.nanoTime() - start) / 1e6 + } + + @Test + fun aRegionKeyCostsWhatThePlanSaysItDoes() { + val point = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940")!! + repeat(30) { RegionKey.at(point, 6) } + repeat(30) { javaRoot(BigInteger.valueOf(123456789L), 6) } + + // The two costs a §7.7 sweep is made of: a box of gap G needs + // 3 * 2^(G/3) axis roots and 2^G combines, and the second dominates. + println("height | axis root ms | combine+2sha ms | java root ms | ratio") + for (height in listOf(8, 10, 12, 14)) { + val base = point.alignedBase(height).x.toUBigInt() + val ours = millis { CantorTree.subtreeRoot(base, height) } + val root = CantorTree.subtreeRoot(base, height) + val reps = if (height <= 10) 50 else 5 + val combine = millis { repeat(reps) { RegionKey.derive(CantorTree.cantorPair(CantorTree.cantorPair(root, root), root)) } } / reps + val java = millis { javaRoot(BigInteger(1, base.toMinimalBytes()), height) } + println("$height | ${fmt(ours)} | ${fmt(combine)} | ${fmt(java)} | ${fmt(ours / java)}x") + } + + // Loose on purpose — a shared box is noisy, and what would invalidate + // the budget model is an order of magnitude, not a busy minute. + val atEight = millis { RegionKey.at(point, 8) } + assertTrue(atEight < 500.0, "a height-8 key should be milliseconds, was ${fmt(atEight)} ms") + } + + @Test + fun theTwoImplementationsAgreeOnTheRootItself() { + // The differential test covers the arithmetic; this covers the fold + // built on it, which is where an off-by-one in the stack would live. + for (height in 0..12) { + // A base with bits set high and low, so the fold is not walking zeros. + val base = UBigInt.of((1L shl 62) + 1_234_567L + height) + val mine = CantorTree.subtreeRoot(base, height) + val theirs = javaRoot(BigInteger(1, base.toMinimalBytes()), height) + assertEquals(theirs, BigInteger(1, mine.toMinimalBytes()), "height $height") + } + } + + private fun fmt(value: Double) = ((value * 100).toLong() / 100.0).toString() +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt new file mode 100644 index 0000000000..7bb3a7245e --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt @@ -0,0 +1,197 @@ +/* + * 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.utils.bigint + +import java.math.BigInteger +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * [PortableUBigInt] against `java.math.BigInteger`, on random inputs, operation by + * operation. + * + * This is the whole safety argument for the second implementation. JVM and + * Android alias the platform's; Apple and Linux run this one. What it computes becomes a decryption key + * (`CYBERSPACE_V2.md` §7.2), so a carry dropped in one limb is not a wrong + * number, it is an object that will not open — and it would open everywhere + * else, because everyone else is running the reference. A hand-written example + * suite cannot cover a carry chain; a few thousand random pairs across the + * sizes the Cantor tree actually reaches can, and the oracle is the + * implementation the rest of the world's JVMs already agree with. + * + * The sizes are chosen to straddle the Karatsuba threshold in both directions + * and to include the degenerate ends, because that boundary is where a + * split-and-recombine bug lives. + */ +class PortableUBigIntDifferentialTest { + private val random = Random(20260923) + + private fun PortableUBigInt.toBig(): BigInteger = BigInteger(1, toMinimalBytes()) + + private fun randomOf(bits: Int): Pair { + if (bits == 0) return PortableUBigInt.ZERO to BigInteger.ZERO + val bytes = ByteArray((bits + 7) / 8) + random.nextBytes(bytes) + // Force the top bit so the value really is this wide, which is also the + // case where BigInteger's own toByteArray grows a sign byte. + bytes[0] = (bytes[0].toInt() or 0x80).toByte() + val mine = PortableUBigInt.ofBytes(bytes) + return mine to BigInteger(1, bytes) + } + + /** Sizes around the split threshold, plus the degenerate ends. */ + private val widths = listOf(0, 1, 7, 8, 31, 32, 33, 64, 127, 128, 1_200, 1_280, 1_281, 4_096, 9_001) + + @Test + fun addingAgrees() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.add(theirsB), (mineA + mineB).toBig(), "$a + $b") + } + } + } + } + + @Test + fun multiplyingAgreesOnBothSidesOfTheKaratsubaThreshold() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.multiply(theirsB), (mineA * mineB).toBig(), "$a * $b") + } + } + } + } + + @Test + fun multiplyingAgreesOnTheSizesACantorTreeReaches() { + // A root at height h is about 86 * 2^h bits, so these are the operands + // of the last few pairings at heights 12 to 16 — where Karatsuba + // recurses several levels deep and a mis-split would show. + for (bits in listOf(44_000, 88_000, 176_000, 352_000)) { + val (mineA, theirsA) = randomOf(bits) + val (mineB, theirsB) = randomOf(bits) + assertEquals(theirsA.multiply(theirsB), (mineA * mineB).toBig(), "$bits bits squared") + } + } + + @Test + fun shiftingRightAgrees() { + for (bits in widths) { + for (by in listOf(0, 1, 7, 31, 32, 33, 64, 1_000, 100_000)) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs.shiftRight(by), mine.shiftRight(by).toBig(), "$bits >> $by") + } + } + } + + @Test + fun comparingAndEqualityAgree() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.compareTo(theirsB), mineA.compareTo(mineB), "$a <=> $b") + assertEquals(theirsA == theirsB, mineA == mineB) + } + } + } + } + + @Test + fun bitLengthAgrees() { + for (bits in widths) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs.bitLength(), mine.bitLength, "$bits") + } + } + + @Test + fun theMinimalBytesAreTheReferencesAndNotJavas() { + // The reference's `int_to_bytes_be_min` is `n.to_bytes((bit_length + 7) + // // 8, "big")`, with a single zero byte for zero. BigInteger's own + // toByteArray is two's complement and prepends 0x00 whenever the top + // bit is set, which is half of all numbers — and §7.2 hashes these + // bytes, so the spare byte would be a different key. + assertEquals(listOf(0), PortableUBigInt.ZERO.toMinimalBytes().toList()) + assertEquals(listOf(1), PortableUBigInt.ONE.toMinimalBytes().toList()) + assertEquals(listOf(-1), PortableUBigInt.of(255).toMinimalBytes().toList()) + assertEquals(listOf(1, 0), PortableUBigInt.of(256).toMinimalBytes().toList()) + + for (bits in widths.filter { it > 0 }) { + val (mine, theirs) = randomOf(bits) + val mineBytes = mine.toMinimalBytes() + // Same value, and never longer than the bit length demands. + assertEquals(theirs, BigInteger(1, mineBytes), "$bits round trip") + assertEquals((theirs.bitLength() + 7) / 8, mineBytes.size, "$bits has no spare byte") + assertTrue(mineBytes[0] != 0.toByte(), "$bits leads with a significant byte") + } + } + + @Test + fun theTwoActualsAgreeWithEachOther() { + // The contract between the platforms, asserted rather than assumed: on + // this JVM `UBigInt` is `java.math.BigInteger`, and on Apple and Linux + // it is `PortableUBigInt`. A region key derived on a phone has to equal + // one derived on a desktop, so the two must agree on every operation + // and, above all, on the bytes that get hashed. + for (a in widths) { + for (b in widths) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + val fastA = UBigInt.ofBytes(mineA.toMinimalBytes()) + val fastB = UBigInt.ofBytes(mineB.toMinimalBytes()) + + assertEquals((mineA + mineB).toMinimalBytes().toList(), (fastA + fastB).toMinimalBytes().toList(), "$a + $b") + assertEquals((mineA * mineB).toMinimalBytes().toList(), (fastA * fastB).toMinimalBytes().toList(), "$a * $b") + assertEquals(mineA.shiftRight(33).toMinimalBytes().toList(), fastA.shiftRight(33).toMinimalBytes().toList(), "$a >> 33") + assertEquals(mineA.compareTo(mineB), fastA.compareTo(fastB), "$a <=> $b") + assertEquals(mineA.bitLength, fastA.bitLength, "$a bits") + assertEquals(mineA.isZero, fastA.isZero, "$a zero") + // And the JVM actual must have stripped the sign byte its + // `toByteArray` grows, which is the one place aliasing would + // have silently changed a key. + assertEquals(theirsA.toString(16).trimStart('0').ifEmpty { "0" }, fastA.toMinimalBytes().toHex(), "$a bytes") + } + } + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }.trimStart('0').ifEmpty { "0" } + + @Test + fun longsAndBytesRoundTrip() { + for (value in listOf(0L, 1L, 255L, 256L, Int.MAX_VALUE.toLong(), 1L shl 32, Long.MAX_VALUE)) { + assertEquals(BigInteger.valueOf(value), PortableUBigInt.of(value).toBig(), "$value") + } + for (bits in widths) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs, PortableUBigInt.ofBytes(mine.toMinimalBytes()).toBig(), "$bits") + } + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt index 209c36a4b7..efc7e36298 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte() diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt new file mode 100644 index 0000000000..f1890770f7 --- /dev/null +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt @@ -0,0 +1,63 @@ +/* + * 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.utils.bigint + +/** + * [UBigInt] over [PortableUBigInt], for Apple and Linux, which have no big + * integer to borrow. + * + * Slower than the JVM's by 3 to 10 times on the operands a Cantor tree reaches, + * and identical in what it produces — which is the part that matters, because + * §7.2 turns it into a key. `PortableUBigIntDifferentialTest` is what holds the + * two together. + */ +actual class UBigInt internal constructor( + internal val raw: PortableUBigInt, +) : Comparable { + actual val bitLength: Int get() = raw.bitLength + + actual val isZero: Boolean get() = raw.isZero + + actual operator fun plus(other: UBigInt): UBigInt = UBigInt(raw + other.raw) + + actual operator fun times(other: UBigInt): UBigInt = UBigInt(raw * other.raw) + + actual fun shiftRight(bits: Int): UBigInt = UBigInt(raw.shiftRight(bits)) + + actual fun toMinimalBytes(): ByteArray = raw.toMinimalBytes() + + actual override fun compareTo(other: UBigInt): Int = raw.compareTo(other.raw) + + actual override fun equals(other: Any?): Boolean = other is UBigInt && raw == other.raw + + actual override fun hashCode(): Int = raw.hashCode() + + override fun toString(): String = "UBigInt($bitLength bits)" + + actual companion object { + actual val ZERO: UBigInt = UBigInt(PortableUBigInt.ZERO) + actual val ONE: UBigInt = UBigInt(PortableUBigInt.ONE) + + actual fun of(value: Long): UBigInt = UBigInt(PortableUBigInt.of(value)) + + actual fun ofBytes(bytes: ByteArray): UBigInt = UBigInt(PortableUBigInt.ofBytes(bytes)) + } +}