mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/sharp-keller-7itny0
This commit is contained in:
@@ -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: <https://github.com/arkin0x/cyberspace/blob/master/decks/DECK-0003-sno.md>
|
||||
(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<List<Int>>`: 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<Int, Int>` 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:<event id>:<size>:<view>`, 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.
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BagSweepState?>(null) }
|
||||
var job by remember(noteEvent) { mutableStateOf<Job?>(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
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) } })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+16
@@ -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) {
|
||||
|
||||
@@ -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 <parse\|work\|verify>` | 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. |
|
||||
|
||||
@@ -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 | 🆕 | |
|
||||
|
||||
@@ -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<String>): 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 <parse|work|verify> Simple Nostr Objects (DECK-0003): validate, price, verify an avatar
|
||||
| cyberspace <coord|region> Cyberspace places (§2), region keys (§7.2), hint boxes
|
||||
| <hint|open|sweep> (§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)
|
||||
|
||||
@@ -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<String>): Int =
|
||||
route(
|
||||
"cyberspace",
|
||||
tail,
|
||||
"cyberspace <coord|region|hint|open|sweep>",
|
||||
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<String>): 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<String>): 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<String>): 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<String>): 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<String>): 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<String>() 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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>): Int =
|
||||
route(
|
||||
"sno",
|
||||
tail,
|
||||
"sno <parse|work|verify>",
|
||||
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<String>): 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<String>): 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<String>): 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<String, Any?> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
state-sno-conformance/
|
||||
Executable
+229
@@ -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]]()
|
||||
Executable
+510
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
+272
@@ -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<BagSweepState> =
|
||||
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))
|
||||
}
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+186
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+450
@@ -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<Long, MutableList<Int>>(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<Int>()
|
||||
val queue = ArrayDeque<Int>()
|
||||
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<Int>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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)
|
||||
}
|
||||
}
|
||||
+705
@@ -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<Long>(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<Long>,
|
||||
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
|
||||
}
|
||||
+190
@@ -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<String>): 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<BagSweepQuote.Searchable>(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<BagSweepQuote.Searchable>(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<BagSweepQuote.OutOfReach>(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<BagSweepQuote.Hidden>(BagSweep.quote(bag()))
|
||||
// Two hint tags is one of §7.7's malformed cases.
|
||||
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4))))
|
||||
// And a box smaller than the region it claims to hold is another.
|
||||
assertIs<BagSweepQuote.Hidden>(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<BagSweepQuote.Hidden>(BagSweep.quote(future))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sweepingTheBoxFindsTheRegionAndOpensTheBag() =
|
||||
runTest {
|
||||
val states = BagSweep.sweep(bag(hint(box666, 6))).toList()
|
||||
|
||||
assertIs<BagSweepState.Measuring>(states.first(), "priced on this device before the rest is spent")
|
||||
val opened = assertIs<BagSweepState.Opened>(states.last())
|
||||
val contents = assertIs<CyberspaceBagContents.Opaque>(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<BagSweepState.Opened>(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<BagSweepState.NotFound>(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())
|
||||
}
|
||||
}
|
||||
+106
@@ -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<Long, Int>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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<Int> = 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+")
|
||||
}
|
||||
}
|
||||
+335
@@ -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)))
|
||||
}
|
||||
}
|
||||
+135
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -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()
|
||||
|
||||
@@ -5320,4 +5320,35 @@
|
||||
<item quantity="one">%1$d new message</item>
|
||||
<item quantity="other">%1$d new messages</item>
|
||||
</plurals>
|
||||
<string name="sno_object_title">3D object</string>
|
||||
<string name="sno_object_details">%1$d vertices · %2$d faces</string>
|
||||
<string name="sno_object_unreadable">This 3D object could not be read (rule %1$s)</string>
|
||||
<string name="sno_avatar_title">Cyberspace avatar</string>
|
||||
<string name="sno_avatar_unpaid">This avatar has not paid for its size, so it is not drawn</string>
|
||||
<string name="sno_light_on">Lit; tap to draw the object flat</string>
|
||||
<string name="sno_light_off">Flat; tap to light the object</string>
|
||||
<string name="sno_object_scale">1 unit = %1$s</string>
|
||||
<string name="sno_shard_dataspace">Hidden in dataspace · %1$s</string>
|
||||
<string name="sno_shard_ideaspace">Hidden in ideaspace · %1$s</string>
|
||||
<string name="sno_avatar_default">Default avatar</string>
|
||||
<string name="sno_avatar_default_details">No shape published, so everyone sees this one</string>
|
||||
<string name="cyberspace_bag_title">Hidden at a place</string>
|
||||
<string name="cyberspace_bag_box">Hidden in a box of %1$s regions</string>
|
||||
<string name="cyberspace_bag_destination">Hidden in one region, which the hint names exactly</string>
|
||||
<string name="cyberspace_bag_no_hint">No hint, so this could be anywhere in cyberspace</string>
|
||||
<string name="cyberspace_bag_out_of_reach">Out of reach: the hint names 2^%1$d regions to search</string>
|
||||
<string name="cyberspace_bag_search">Search for it</string>
|
||||
<string name="cyberspace_bag_cancel">Stop</string>
|
||||
<string name="cyberspace_bag_measuring">Pricing the search on this device…</string>
|
||||
<string name="cyberspace_bag_progress">Searched %1$s of %2$s regions</string>
|
||||
<string name="cyberspace_bag_too_slow_minutes">Out of reach: about %1$d minutes of searching on this device</string>
|
||||
<string name="cyberspace_bag_too_slow_hours">Out of reach: about %1$d hours of searching on this device</string>
|
||||
<string name="cyberspace_bag_not_found">Not in the box the hint named</string>
|
||||
<string name="cyberspace_bag_damaged">Found the region, but the contents could not be read</string>
|
||||
<string name="cyberspace_bag_dropped">%1$d item hidden here did not match its signature and was dropped</string>
|
||||
<string name="cyberspace_bag_dropped_many">%1$d items hidden here did not match their signatures and were dropped</string>
|
||||
<string name="cyberspace_bag_empty">Opened, and there was nothing inside</string>
|
||||
<string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string>
|
||||
<string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string>
|
||||
<string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string>
|
||||
</resources>
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
@@ -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<SnoObjectToRender> {
|
||||
override fun create(
|
||||
data: SnoObjectToRender,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher = SnoFetcher(data)
|
||||
}
|
||||
|
||||
object SKeyer : Keyer<SnoObjectToRender> {
|
||||
override fun key(
|
||||
data: SnoObjectToRender,
|
||||
options: Options,
|
||||
): String = data.cacheKey
|
||||
}
|
||||
}
|
||||
+304
@@ -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<ImageBitmap?>(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
|
||||
}
|
||||
+79
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+169
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+138
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
@@ -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()
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+59
@@ -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")
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+171
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -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()
|
||||
|
||||
|
||||
@@ -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 <hex> # x, y, z, plane
|
||||
amy cyberspace region <hex> <height> # region_n, key, lookup_id
|
||||
amy cyberspace hint <bag.json> # box, candidates, estimated cost
|
||||
amy cyberspace sweep <bag.json> [--budget] # the lookup_ids, or the key when found
|
||||
amy cyberspace open <bag.json> --key <hex> # 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.
|
||||
@@ -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 |
|
||||
|
||||
+6
@@ -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. */
|
||||
|
||||
@@ -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<UBigInt>(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
|
||||
}
|
||||
}
|
||||
+246
@@ -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<CyberspaceBagItem>,
|
||||
/** 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<Array<String>>,
|
||||
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<CyberspaceBagItem>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Array<String>> {
|
||||
val out = mutableListOf<Array<String>>()
|
||||
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<String> = 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<Array<String>>,
|
||||
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<String>,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<RegionKeyMaterial> {
|
||||
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<UBigInt> {
|
||||
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<UBigInt>(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)
|
||||
}
|
||||
+122
@@ -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<Array<String>>,
|
||||
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
|
||||
}
|
||||
}
|
||||
+89
@@ -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"),
|
||||
}
|
||||
}
|
||||
+86
@@ -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)
|
||||
}
|
||||
+85
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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 }
|
||||
}
|
||||
}
|
||||
+92
@@ -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 <a href="https://github.com/arkin0x/cyberspace/blob/master/decks/DECK-0003-sno.md">DECK-0003</a>
|
||||
*/
|
||||
@Immutable
|
||||
class SnoObjectEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
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<SnoObjectEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, payloadJson, createdAt) {
|
||||
dTag(dTag)
|
||||
name?.let { add(arrayOf("name", it)) }
|
||||
alt(name?.let { "$ALT: $it" } ?: ALT)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+120
@@ -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
|
||||
}
|
||||
}
|
||||
+455
@@ -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
|
||||
}
|
||||
}
|
||||
+119
@@ -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()
|
||||
}
|
||||
}
|
||||
+114
@@ -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<Array<String>>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+37
-11
@@ -196,6 +196,8 @@ class MlsGroup private constructor(
|
||||
*/
|
||||
internal fun groupContextExtensionsSnapshot(): List<Extension> = 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<Extension> = emptyList(),
|
||||
capabilities: Capabilities = marmotLeafCapabilities(),
|
||||
keyPackageExtensions: List<Extension> = 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<Extension> = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+276
@@ -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<PortableUBigInt> {
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
@@ -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<UBigInt> {
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
+214
@@ -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<String>,
|
||||
): 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")
|
||||
}
|
||||
}
|
||||
+168
@@ -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"))
|
||||
}
|
||||
}
|
||||
+223
@@ -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] })
|
||||
}
|
||||
}
|
||||
+80
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<String>.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<IllegalArgumentException> {
|
||||
CantorTree.subtreeRoot(UBigInt.ZERO, CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT + 1)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> { 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)))
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalArgumentException> { 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<IllegalArgumentException> { 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")
|
||||
}
|
||||
}
|
||||
+126
@@ -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())
|
||||
}
|
||||
}
|
||||
+194
@@ -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)
|
||||
}
|
||||
}
|
||||
+111
@@ -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")
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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<Array<String>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+344
@@ -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}"""
|
||||
}
|
||||
+108
@@ -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))
|
||||
}
|
||||
}
|
||||
+98
File diff suppressed because one or more lines are too long
+81
@@ -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<Array<String>> = emptyArray(),
|
||||
) = SnoShardEvent("aa".repeat(32), "11".repeat(32), 0L, tags, content, "22".repeat(64))
|
||||
|
||||
private val payloadJson =
|
||||
"""{"v":2,"name":"hidden","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}"""
|
||||
|
||||
@Test
|
||||
fun aShardCarriesThePayloadInContent() {
|
||||
val payload = shard(content = payloadJson).shardOrNull()
|
||||
assertNotNull(payload)
|
||||
assertEquals("hidden", payload.name)
|
||||
assertEquals(2, payload.version)
|
||||
assertEquals(4, payload.vertexCount)
|
||||
assertEquals(1, payload.faceCount)
|
||||
assertEquals(0xFFFF0000.toInt(), payload.colors[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aShardIsHeldToTheSameRulesAsAnObject() {
|
||||
// The container changed; §1.9 did not. This is one format in two
|
||||
// containers, not two ways of writing the format.
|
||||
val result = shard(content = """{"v":2,"name":"x","unit":0,"mode":"wireframe","vertices":[],"colors":[],"faces":[]}""").shard()
|
||||
assertTrue(result is SnoResult.Invalid)
|
||||
assertEquals("4", result.rule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun 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())
|
||||
}
|
||||
}
|
||||
+6
@@ -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()
|
||||
|
||||
+79
@@ -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<UBigInt> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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<MlsGroup, MlsGroup> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+74
@@ -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<MlsGroup, MlsGroup> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<BigInteger>(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()
|
||||
}
|
||||
+197
@@ -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<PortableUBigInt, BigInteger> {
|
||||
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<Byte>(0), PortableUBigInt.ZERO.toMinimalBytes().toList())
|
||||
assertEquals(listOf<Byte>(1), PortableUBigInt.ONE.toMinimalBytes().toList())
|
||||
assertEquals(listOf<Byte>(-1), PortableUBigInt.of(255).toMinimalBytes().toList())
|
||||
assertEquals(listOf<Byte>(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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()
|
||||
|
||||
@@ -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<UBigInt> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user