Compare commits

..
Author SHA1 Message Date
Meta Vens
28f1d22ff0 docs: Ratatoskr map: the album write path is three one-repo cuts (mouth, ops, Persona)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:36:04 +01:00
Meta Vens
5d93dd2d12 docs: Ratatoskr map: per-Persona file zone as albums (one level, a thread per album), the write path, the zone view and the mel surface; throwaway Synapse probe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:34:52 +01:00
99 changed files with 417 additions and 5259 deletions

View file

@ -17,9 +17,5 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},
"backgroundScene": {
"tier": "off"
}
}

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Throwaway Synapse probe for the Persona file zone (album) shape. Never touches the live server.
set -u
D=/tmp/imag-zone-probe; NAME=imag-zone-probe; IMG=matrixdotorg/synapse:v1.164.1; PORT=18999
docker rm -f $NAME >/dev/null 2>&1; rm -rf $D; mkdir -p $D/data
docker run --rm -v $D/data:/data -e SYNAPSE_SERVER_NAME=probe.test -e SYNAPSE_REPORT_STATS=no $IMG generate >/dev/null 2>&1 || { echo "generate failed"; exit 1; }
printf 'enable_registration: false\nregistration_shared_secret: "probe-only-secret"\n' >> $D/data/homeserver.yaml
docker run -d --name $NAME -p 127.0.0.1:$PORT:8008 -v $D/data:/data $IMG >/dev/null
for i in $(seq 60); do curl -fs http://127.0.0.1:$PORT/health >/dev/null && break; sleep 1; done
docker exec $NAME register_new_matrix_user -u aqua -p probe-pass -a -k probe-only-secret http://localhost:8008 >/dev/null 2>&1
HS=http://127.0.0.1:$PORT
TOK=$(curl -s -XPOST $HS/_matrix/client/v3/login -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"aqua"},"password":"probe-pass"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
A="Authorization: Bearer $TOK"
j() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1', d))"; }
echo "versions has v1.11: $(curl -s $HS/_matrix/client/versions | grep -c v1.11)"
grep -n "max_upload_size\|enable_authenticated_media" $D/data/homeserver.yaml || echo "max_upload_size: default (not in generated config)"
ROOM=$(curl -s -XPOST -H "$A" $HS/_matrix/client/v3/createRoom -d '{"name":"aqua files","room_alias_name":"files-aqua"}' | j room_id); echo "room $ROOM"
head -c 300000 /dev/urandom > $D/a.wav
MXC=$(curl -s -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/media/v3/upload?filename=a.wav" | j content_uri); echo "upload v3: $MXC"
echo "upload v1 path: $(curl -s -o /dev/null -w '%{http_code}' -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/client/v1/media/upload?filename=a.wav")"
R=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$ROOM")
ROOT=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio","org.gamecult.album":{"name":"thruster-v3","digest":"x","files":1,"follows":null}}' | j event_id); echo "root $ROOT"
ROOT2=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio"}' | j event_id); echo "same txn again -> $ROOT2 (same id means idempotent)"
REP=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-0" -d "{\"msgtype\":\"m.audio\",\"body\":\"a.wav\",\"filename\":\"a.wav\",\"url\":\"$MXC\",\"info\":{\"mimetype\":\"audio/wav\",\"size\":300000},\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$ROOT\",\"is_falling_back\":true,\"m.in_reply_to\":{\"event_id\":\"$ROOT\"}}}"); echo "thread reply: $REP"
REF=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-root" -d "{\"msgtype\":\"m.text\",\"body\":\"Album v4\",\"m.relates_to\":{\"rel_type\":\"m.reference\",\"event_id\":\"$ROOT\"}}" | j event_id); echo "root with m.reference: $REF"
echo "thread off a root that has a relation: $(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-0" -d "{\"msgtype\":\"m.text\",\"body\":\"f\",\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$REF\"}}")"
MID=${MXC#mxc://}
echo "auth download v1 with token: $(curl -s -o /dev/null -w '%{http_code}' -H "$A" $HS/_matrix/client/v1/media/download/$MID)"
echo "auth download v1 no token: $(curl -s -o /dev/null -w '%{http_code}' $HS/_matrix/client/v1/media/download/$MID)"
echo "range request v1: $(curl -s -o /dev/null -w '%{http_code} %{size_download}' -H "$A" -H 'Range: bytes=0-99' $HS/_matrix/client/v1/media/download/$MID)"
echo "threads list: $(curl -s -H "$A" "$HS/_matrix/client/v1/rooms/$R/threads" | python3 -c 'import sys,json;d=json.load(sys.stdin);print([(e["event_id"]==sys.argv[1], e["unsigned"].get("m.relations",{}).get("m.thread",{}).get("count")) for e in d["chunk"]])' "$ROOT")"
head -c 60000000 /dev/zero > $D/big.bin
echo "60MB upload: $(curl -s -w ' %{http_code}' -XPOST -H "$A" -H 'Content-Type: application/octet-stream' --data-binary @$D/big.bin "$HS/_matrix/media/v3/upload?filename=big.bin" | tail -c 160)"
docker rm -f $NAME >/dev/null; sudo -n rm -rf $D 2>/dev/null || rm -rf $D; echo "cleaned"

View file

@ -300,6 +300,185 @@ as of 2026-10-31 ~15:00.
(`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's
room ids; a Web Push gateway if the operator picks it.
## File zone: albums
Rulings: `ratatoskr:ruling:persona-file-sharing-zone`,
`ratatoskr:ruling:file-zone-albums-one-level`,
`ratatoskr:ruling:file-zone-looks-like-file-sharing`; under them
`eureka-body:ruling:mind-notes-via-personas` (a Self speaks only through her
Persona) and `eureka-body:ruling:live-mind-sessions` (the Persona organ will
become a live session; the album document below does not depend on how her
reply is produced). Eyes facts: `docs/research/2026-10-31-persona-file-zone.md`
on `ratatoskr/research-atmosphere`.
A Mind's Self makes files (Aqua: `render-thruster-v1` to `-v5`, each a flat
folder of three WAVs of 0.4 to 0.6 MB beside build junk). She shares them as an
**album**: one level, a name, a short note, a set of files, and optionally the
earlier album it follows. Ratatoskr shows each Persona's albums as a
file-sharing space; Matrix is the storage and the fallback for other clients.
### Shape, and why
- **One room per Persona is her file zone**, created by provisioning with
room type `org.gamecult.file_zone` (`creation_content.type`, immutable, and
returned as `room_type` by `/hierarchy`, which `buildEstate` already reads).
The zone owner is the room's steward, carried in its `m.space.child` entry as
for project rooms.
- **An album is a thread.** The root is an `m.text` whose body is the
human-readable fallback (name, counts, note) and whose content carries
`org.gamecult.album`: `name`, `digest`, `files`, `kinds`, `bytes`, `follows`
(the root event id of the album it follows, or null) and `request_id`. Each
file is an `m.audio`/`m.image`/`m.video`/`m.file` event in that thread
(`m.thread`, `is_falling_back`, `m.in_reply_to` the root), which every
client already renders through authenticated media.
- **`follows` is a content field, not a relation.** Probe (below): Synapse
refuses a thread off an event that carries `m.relates_to` ("Cannot start
threads from an event with a relation"), so a root that `m.reference`d its
predecessor could not hold its own files.
- **Rejected:** MSC3089 trees (stalled since 2021, no client or server
support); a directory served by nginx (no access check exists, and it would
be a second store beside Matrix); posting albums as threads in the
conversation room (Ratatoskr runs with matrix-js-sdk `threadSupport` off,
`initMatrix.ts:44`, so thread replies land in the main timeline and one
album floods the room); turning `threadSupport` on (Ratatoskr has no thread
panel, so replies would vanish from view).
### Model page: albums
| Kind | Named by | Over time | Who decides |
|---|---|---|---|
| Outbox album | directory `/var/lib/mind-<slug>-outbox/<album>/` | written and replaced by her Self | her Self (only writer) |
| Album choice | an `albums[]` item of her Persona's reply | per turn | her Persona |
| Share request | `gamecult.persona.chat_share_request.v1` | one per album per turn | the Persona organ emits; the mouth acts |
| File zone | room alias `#files-<slug>:gamecult.org`, room type `org.gamecult.file_zone` | created once | provisioning (`synapse-accounts-hand-managed.py`) |
| Album | thread root carrying `org.gamecult.album` in her zone | immutable once posted; a revision is a new album that `follows` it | the mouth posts, as her account |
| Zone view | derived per render from the zone room's threads | never stored | the client |
### Authority map: write path (cuts `album-share-mouth`, `album-share-persona`)
- **Owner:** the mouth (`Bifrost tools/persona-matrix-mouth.mjs`) owns
turning one share request into one album in her zone. The zone room is the
record of what has been shared: the mouth keeps no memory of shares.
- **Inputs:** the share request (album name, announce room, note, follows);
the album directory, read as user `bifrost` through a read ACL; her zone
room's threads (`GET /_matrix/client/v1/rooms/{id}/threads`) and the
album's thread (`/relations/{root}/m.thread`); her brake.
- **Outputs:** the root, one event per file, one announce line in the named
room (her note, the album name and count, a `matrix.to` link to the root),
and a `chat_send_receipt` (root event id, or a refusal).
- **Idempotency is derived from Matrix:** txn ids are `request_id`,
`request_id-f<i>`, `request_id-ann` (probe: a repeated txn returns the same
event); beyond the txn window, a root in her zone with the same `name` and
`digest` is the album, and its thread's filenames say which files still need
posting, so a retry resumes rather than duplicates.
- **Forbidden writers:** the Self never touches Matrix and holds no Matrix
credential; the Persona organ never uploads and never reads file contents;
nothing but the mouth posts albums; no cache or ledger of "shared" albums
exists outside the zone room.
- **Demoted:** the Persona's `say` stays text only; a link to a file in her
sandbox is no longer how a Mind shares.
- **Shared paths:** every album, whatever the Mind or turn, goes through the
one share handler; a retry and a first attempt run the same code.
### Outbox and limits
- `/var/lib/mind-<slug>-outbox`, owned `mind-<slug>-self`, mode 0750, ACL
`u:mind-<slug>:r-x` (her Persona lists it) and `u:bifrost:r-x` (the mouth
reads it), with the same default ACLs. Created by
`install-mind-units-yggdrasil.sh`; `mind-self@.service` gains
`ReadWritePaths=/var/lib/mind-%i-outbox` (her state directory is 0700 and
`ProtectSystem=strict` makes the rest of `/var` read-only).
- An album is one level: regular files only, named by
`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; a subdirectory, symlink, dotfile, a file
not owned by `mind-<slug>-self`, or an unreadable file rejects the album
with its name (files are opened `O_NOFOLLOW` and checked with `lstat`, so an
album cannot reach another Mind's outbox or the mouth's own state).
- Per file at most 50 MiB (live Synapse `max_upload_size: 50M`; the mouth
uploads on loopback `127.0.0.1:8834`, so nginx's `50m` does not apply); per
album at most 100 files and 500 MiB. Kind by extension: wav, ogg, oga, opus,
flac, mp3, m4a are `m.audio`; png, jpg, jpeg, webp, gif are `m.image`; mp4,
webm are `m.video`; anything else is `m.file` (`application/octet-stream`).
- `digest` is SHA-256 over the sorted list of (filename, size, SHA-256 of
content); files post in bytewise filename order.
### Authority map: zone view (cut `file-zone-view`)
- **Owner:** one zone model in the client, a pure `buildAlbums()` over the
zone room's thread roots and an album's thread, fed by one hook; it decides
which albums show, in what order, and which events are an album's files.
- **Inputs:** `/hierarchy` (`room_type` marks zones; the estate index already
holds it); `GET /v1/rooms/{id}/threads` paged (probe: lists roots with
their reply counts); `/relations/{root}/m.thread` for one album.
- **Outputs:** albums (title, file count and kinds, date, `follows` chain) and
one album's files.
- **Derived:** files are the zone owner's media replies only; others' replies
are comments; the `follows` chain is display-only grouping.
- **Forbidden writers:** the timeline does not decide what an album holds; no
local store of albums.
- **Mount:** the navigator lists zones as their own branch kind from
`buildEstate`; opening one shows the zone view, with "Open as room" as the
fallback to the plain timeline (where the flood is accepted).
- **Media:** every fetch goes through `mxcUrlToHttp` with authentication and
the service worker's bearer header (`sw.ts:107`); probe: download without a
token is 401. Synapse answered a `Range` request with the whole body (200),
so playback fetches whole files, as `AudioContent` already does.
- **Parts:** the album list is our own folds component (no library needed);
images through `react-photo-album` (81 KB unpacked) and
`yet-another-react-lightbox` (253 KB unpacked), loaded only by the zone view
chunk; audio reuses `AudioContent` with one album queue (play all, next,
previous). The audio preview is the mel surface below
(`ratatoskr:ruling:audio-preview-mel-surface`), not a waveform, so
`@wavesurfer/react` is not adopted. No file-manager
library: chonky is abandoned and its fork archived, and the zone is
read-only.
### Audio preview: the mel surface
- **The mouth computes the sidecar at upload**, for every Mind, not Aqua's
tooling on export. One writer serves all eight Minds; AquaSynth's features
serve one Mind and would make the Self a second writer of a file the mouth
must pair by name. Album events are immutable, so the sidecar ships in the
write path (`album-share-mouth`), or albums shared before it never get one.
- **What it is:** a 256 x 64 8-bit grayscale PNG (time across, 64 mel bands
down, low band at the bottom): a mono mix, Hann window, FFT 2048 at the
file's rate, 256 frames spread evenly over the whole file, log power from
-80 dB to the file's peak mapped onto 0..255. A few KB; built with Node's own
`zlib` and an in-module FFT, no new dependency. A PNG because the browser
decodes it natively straight into a WebGL texture (`texImage2D` from an
`ImageBitmap`), with no parser of ours on the phone.
- **Scope:** PCM WAV (16-bit, 24-bit, 32-bit float), which is what Aqua makes.
Other audio posts with no sidecar and its tile shows a plain placeholder;
decoding compressed audio in the mouth waits for a Mind that shares it.
- **How it travels:** its own upload, referenced from the audio event's
content as `org.gamecult.mel: { url, bands: 64, frames: 256, db_floor: -80 }`.
Not inline data (about 22 KB of base64 on every event every client syncs),
and not an event of its own (other clients would show it as a stray file,
and the view would pair it by filename). The digest covers the album's own
files, not sidecars.
- **The shader:** one WebGL2 component in the zone view chunk: a 256 x 64
grid (about 16k vertices) displaced in the vertex shader by the texture,
normals from neighbouring texels, a lit swept ribbon with time along the
sweep, mel across, energy as height and colour, and the playhead as a
uniform. Nibu owns the shader (she holds the scene shaders); Void
integrates; CultMath is not needed. Tiles never get a context each
(browsers cap live contexts at about 16, and the background already holds
one): one shared offscreen context renders each tile's still once into an
`ImageBitmap`, cached per mxc for the session; only the open file animates.
Gate: the open file's frame time measured on the operator's phone with the
background running, before the cut is called done.
### Probe (2026-10-31, throwaway Synapse v1.164.1 container on Yggdrasil, removed after)
| Claim | Result |
|---|---|
| Upload path | `POST /_matrix/media/v3/upload` 200 with mxc; `/_matrix/client/v1/media/upload` 404 |
| Same txn id twice | same event id |
| File as thread reply with `is_falling_back` | accepted |
| Root carrying `m.reference`, then a thread off it | refused: "Cannot start threads from an event with a relation" |
| `GET /_matrix/client/v1/media/download` with token / without | 200 / 401 |
| `Range: bytes=0-99` | 200, whole body |
| `GET /_matrix/client/v1/rooms/{id}/threads` | lists the root with count 1 |
## Cut sequence
`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`).
@ -344,6 +523,27 @@ Each later cut is one line, with what it waits on.
metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`.
- `attention-bubbles`: per-person attention bubbles from Huginn's typed
attention document, scoped to room members; waits on Huginn publishing it.
- `album-share-mouth` (Bifrost): the mouth turns a `chat_share_request` into
an album in her zone (upload, mel sidecar for WAV, thread, announce line,
receipt, resume from Matrix), against a fake homeserver and a temp outbox;
specced (`ratatoskr:cut_spec:cut-album-share-mouth.r1`); waits on nothing.
- `album-outbox-ops` (gamecult-ops): the outboxes and their ACLs, the Self
unit's `ReadWritePaths`, a line in the Self seed text saying how to share,
the zone rooms in `synapse-accounts-hand-managed.py` (room type, steward in
the space child, one alias file per Mind) and `MATRIX_FILE_ZONE_<SLUG>` in
`mouth.env`; waits on `album-share-mouth` (its variable names) and on
`ratatoskr:question:file-zone-audience`.
- `album-share-persona`: Huginn's Persona organ lists her outbox albums in
her turn, her reply gains `albums[]` (name and follows constrained to the
listed albums, room to her rooms), and the organ sends each as a
`chat_share_request`; waits on `album-share-mouth` merging (it owns the
document) and on `album-outbox-ops` deployed; if the live-session rebuild (`eureka-body:ruling:live-mind-sessions`)
lands first, the same document is emitted from wherever her reply is decoded.
- `file-zone-view`: zones as their own branch kind in the navigator, the
album list, an album's files with image grid and lightbox, an album queue
over `AudioContent`, and the mel surface (Nibu's shader); waits on
`estate-navigator` and on albums existing; gated by a phone frame-time
measurement.
- `rename-ratatoskr`: package name, app name and branding, manifest and
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
reference that is ours (not upstream engine paths); waits on Void's forge

View file

@ -1,46 +0,0 @@
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
// non-zero when the page or the fake homeserver's log says otherwise.
const fs = require('fs');
const fails = [];
exports.expect = (label, ok, detail = '') => {
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed. */
exports.done = () => (fails.length ? 4 : 0);
/** The fake homeserver's request log (HSLOG is shared with the server process). */
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
/** Bodies of the account-data PUTs for m.direct, in order. */
exports.mDirectPuts = () =>
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
/** Join requests the server saw for a room id. */
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
const tierOf = (pg, name) =>
pg.evaluate((n) => {
const s = document.body.innerText.split('\n').map((x) => x.trim());
const i = s.indexOf(n);
if (i < 0) return 'absent';
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
return 'none-above';
}, name);
/**
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
* opened only if the row is not already showing, so reading never closes what is open.
*/
exports.revealedTier = async (pg, name) => {
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.waitForTimeout(2500);
let t = await tierOf(pg, name);
for (const h of HEADERS) {
if (t !== 'absent') break;
const hl = pg.getByText(h, { exact: true });
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
}
return t;
};
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
exports.sawText = (pg, re, ms) =>
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
exports.isDm = (tier) => /^direct messages$/i.test(tier);

View file

@ -1,7 +1,6 @@
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
// against fakehs.cjs with the env flag each mode needs.
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE;
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
@ -20,10 +19,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
await open();
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); };
const acceptInInbox = async (name) => {
// reach the inbox through the navigator's own Inbox tab
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
@ -40,33 +38,23 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
};
if (MODE === 'pjfail') {
await tap('Friend DM'); await pg.waitForTimeout(3000);
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry');
} else if (MODE === 'jdrop') {
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
await tap('Friend DM'); await pg.waitForTimeout(4000);
const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message');
await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload');
} else if (MODE === 'admin') {
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate');
} else if (MODE === 'inboxadmin') {
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox');
} else if (MODE === 'inboxreload') {
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
await pg.waitForTimeout(30000); await tiers('inbox reload');
} else if (MODE === 'pjreload') {
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
await pg.waitForTimeout(8000); await tiers('pjreload');
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
expect('a join cut off by a reload stays joinable', n === 1, String(n));
const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count());
}
await quit(done());
await quit(0);
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -1,42 +0,0 @@
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
(async () => {
b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const pg = await ctx.newPage();
await pg.goto('http://127.0.0.1:8080/');
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
let joined = false;
if (MODE === 'deeplink') {
for (const route of ['direct', 'home']) {
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
}
const f = await tiers('after deeplink join');
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
expect('the deep link offers one Join button', joined, 'no Join button');
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
expect('the DM is filed under Direct Messages', isDm(f), f);
} else if (MODE === 'gw504') {
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -1,10 +1,8 @@
const { chromium } = require('playwright');
const { expect, done, isDm, revealedTier } = require('./check.cjs');
const MODE = process.env.MODE; // twodm | reload
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let tierBefore;
(async () => {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
@ -15,14 +13,14 @@ let tierBefore;
await open();
const text = () => pg.evaluate(() => document.body.innerText);
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
if (MODE === 'twodm') {
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
await pg.waitForTimeout(45000);
} else if (MODE === 'convert') {
await tap('Friend DM'); await pg.waitForTimeout(6000);
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
log('TIER before', await tierOf('Friend DM'));
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
@ -37,12 +35,7 @@ let tierBefore;
log('RELOAD', t()); await open();
await pg.waitForTimeout(30000);
}
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
else if (MODE === 'convert') {
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
await b.close(); process.exitCode = done();
for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM'));
await b.close();
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

View file

@ -91,7 +91,6 @@ http.createServer((req, res) => {
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
return json(res, 200, { room_id: id });

View file

@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
echo BUILD_OK $(git rev-parse HEAD)
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/
set +e
for m in twodm reload convert; do
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
done
failure() { m=$1; shift
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
failure pjfail FAIL_MDIRECT=1
@ -29,7 +29,4 @@ failure jdrop JOIN_DROP=1
failure admin INVITER_ADMIN=1
failure inboxadmin INVITER_ADMIN=1
failure inboxreload JOIN_DELAY=1
DRIVER=drive-routes failure deeplink
DRIVER=drive-routes failure gw504 JOIN_504=1
echo "FAILED_DRIVES:${FAILED:- none}"
[ -z "$FAILED" ]
echo DONE

View file

@ -1,21 +0,0 @@
# Scene harness
Compiles, renders and times the background scene (`src/app/features/scene/`) in headless
Chromium. Not shipped, not run by CI.
node bundle.cjs <outdir> # esbuild bundle of entry.ts plus an index.html
node drive.cjs <outdir> compile # compile and link each stage, JSON report
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
node drive.cjs <outdir> phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound`
Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a
host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a
machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a
directory whose ancestors hold no empty `package.json` (esbuild reads them).
Timings from SwiftShader are CPU time: use them for the split between passes and for before and
after of one change, never as a phone frame time.

View file

@ -1,40 +0,0 @@
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
// usage: node bundle.cjs <outdir> [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page.
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const out = process.argv[2];
const demo = process.argv[3] === 'demo';
fs.mkdirSync(out, { recursive: true });
const rawPlugin = {
name: 'raw',
setup(b) {
b.onResolve({ filter: /\?raw$/ }, (a) => ({
path: path.resolve(a.resolveDir, a.path.slice(0, -4)),
namespace: 'raw',
}));
b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({
contents: fs.readFileSync(a.path, 'utf8'),
loader: 'text',
}));
},
};
esbuild
.build({
entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')],
bundle: true,
outfile: path.join(out, 'bundle.js'),
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
})
.then(() => {
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
(demo
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
: '') +
'<script src=bundle.js></script>'
);
});

View file

@ -1,102 +0,0 @@
// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and
// a switch for the cone prepass. Not shipped.
import {
createScene,
Scene,
SceneStats,
SceneUnavailable,
TraceStats,
} from '../../src/app/features/scene/Scene';
import { LEVELS } from '../../src/app/features/scene/budget';
let canvas = document.getElementById('c') as HTMLCanvasElement;
const out = document.getElementById('out') as HTMLPreElement;
const box = document.getElementById('prepass') as HTMLInputElement;
const pull = document.getElementById('pull') as HTMLInputElement;
const half = document.getElementById('half') as HTMLInputElement;
const dev = document.getElementById('dev') as HTMLDetailsElement;
const info = document.getElementById('info') as HTMLPreElement;
const timerless = (() => {
const g = document.createElement('canvas').getContext('webgl2');
return !g?.getExtension('EXT_disjoint_timer_query_webgl2');
})();
const WHY: Record<SceneUnavailable, string> = {
'no-webgl2': 'this browser gave no WebGL2 context',
'no-float-or-half-float-colour-buffer':
'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is',
};
// What the browser says about itself, read from a scratch context so it shows even when the scene
// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU.
function describeDevice(): string {
const c = document.createElement('canvas');
const gl2 = c.getContext('webgl2');
const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null);
const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`];
if (!gl) return [...lines, 'webgl1 context: NO'].join('\n');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
lines.push(
`renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`,
`vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`,
`version: ${gl.getParameter(gl.VERSION)}`,
`user agent: ${navigator.userAgent}`
);
const ext = gl.getSupportedExtensions() ?? [];
for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2'])
lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`);
lines.push(`extensions (${ext.length}): ${ext.join(' ')}`);
return lines.join('\n');
}
info.textContent = describeDevice();
let scene: Scene | null = null;
let stats: SceneStats | undefined;
let trace: TraceStats | undefined;
function start() {
scene?.dispose();
stats = trace = undefined;
// A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound.
const fresh = canvas.cloneNode() as HTMLCanvasElement;
canvas.replaceWith(fresh);
canvas = fresh;
scene = createScene({
canvas: fresh,
unthrottled: true,
fixedLevel: LEVELS[0],
fixedPull: +pull.value,
prepass: box.checked,
halfStart: half.checked,
onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`),
onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`),
onStats: (s) => (stats = s),
onTraceStats: (t) => (trace = t),
});
dev.open = !scene;
}
setInterval(() => {
if (!scene) return;
const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`];
if (stats) {
const ms = stats.passMs;
const total = Object.values(ms).reduce((a, b) => a + b, 0);
lines.push(
`gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`,
`solid ${stats.solidSize.join('x')}`
);
} else if (timerless) {
lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2');
}
if (trace)
lines.push(
`steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)`
);
out.textContent = lines.join('\n');
}, 500);
box.addEventListener('change', start);
half.addEventListener('change', start);
pull.addEventListener('input', start);
start();

View file

@ -1,309 +0,0 @@
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
// usage: node drive.cjs <bundledir> <mode> [outdir] modes: compile | shots | measure
// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report.
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const [, , dir, mode, outdir = '.'] = process.argv;
(async () => {
const b = await chromium.launch({
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
],
});
const page = await b.newPage({ viewport: { width: 960, height: 540 } });
page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`));
page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`));
await page.goto('file://' + path.resolve(dir, 'index.html'));
const report = { mode };
report.renderer = await page.evaluate(() => {
const g = document.createElement('canvas').getContext('webgl2');
const e = g.getExtension('WEBGL_debug_renderer_info');
return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER);
});
if (mode === 'compile') {
report.stages = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2');
const S = window.__scene;
return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => {
const t0 = performance.now();
try {
const p = S.linkStage(gl, stage);
return {
stage,
ok: true,
ms: +(performance.now() - t0).toFixed(1),
sourceBytes: S.fragmentSource(stage).length,
log: gl.getProgramInfoLog(p),
};
} catch (e) {
return {
stage,
ok: false,
ms: +(performance.now() - t0).toFixed(1),
log: String(e.log || e),
};
}
});
});
}
if (mode === 'phacelle') {
report.probe = await page.evaluate(() =>
window.__probePhacelle(
window.__scene.WORLD.erodeSide.slice(),
window.__scene.WORLD.erodeNorm,
4
)
);
}
if (mode === 'shots') {
await page.evaluate(() => {
const c = document.getElementById('c');
window.__s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[0],
});
});
const wait = (ms) => page.waitForTimeout(ms);
await wait(4000);
await page.screenshot({ path: path.join(outdir, 'rest.png') });
await page.evaluate(() => window.__s.login());
await wait(3500);
await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') });
await wait(9000);
await page.screenshot({ path: path.join(outdir, 'pulled-back.png') });
report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png'];
}
if (mode === 'measure' || mode === 'variants') {
const VARIANTS = {
baseline: null,
// What a public value-only fbm would cost: the same octaves without the gradient work.
valueOnlyFbm: [
'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }',
'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }',
],
noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'],
// Upper bound on what skipping sky rays would save: every upward ray misses at once.
noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'],
noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'],
};
const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline'];
const sizes = [
[960, 540],
[540, 1200],
];
report.runs = [];
for (const [w, h] of sizes) {
for (const [li, variant] of wanted.flatMap((v) =>
mode === 'variants'
? [[0, v]]
: [
[0, v],
[3, v],
]
)) {
await page.setViewportSize({ width: w, height: h });
const r = await page.evaluate(
({ li, rep }) =>
new Promise((res) => {
const rows = [];
const c = document.getElementById('c');
const s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[li],
patchSource: rep
? (st, src) =>
st === 'terrain'
? rep[0].startsWith('#define')
? src.replace(/#define W_ERODE_ENABLED \d/, rep[1])
: src.split(rep[0]).join(rep[1])
: src
: undefined,
onStats: (st) => {
rows.push(st);
if (rows.length === 40) {
s.dispose();
const keep = rows.slice(10);
const med = (k) =>
keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1];
res({
solid: keep[0].solidSize,
atm: keep[0].atmosphereSize,
medianMs: {
terrain: med('terrain'),
atmosphere: med('atmosphere'),
resolve: med('resolve'),
composite: med('composite'),
},
});
}
},
});
if (!s) res({ error: 'createScene returned null' });
}),
{ li, rep: VARIANTS[variant] }
);
report.runs.push({ viewport: [w, h], level: li, variant, ...r });
}
}
}
if (mode === 'trace') {
// Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms.
const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1');
report.runs = [];
for (const [w, h] of process.env.PORTRAIT === '0'
? [[960, 540]]
: [
[960, 540],
[540, 1200],
]) {
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
for (const prepass of arms) {
await page.setViewportSize({ width: w, height: h });
const r = await page.evaluate(
({ pull, prepass, FRAMES }) =>
new Promise((res) => {
const rows = [];
const trace = [];
let done = false;
const finish = () => {
if (done || rows.length < FRAMES || trace.length < 4) return;
done = true;
s.dispose();
const keep = rows.slice(FRAMES >> 2);
const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1];
const tk = trace.slice(1);
const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length;
res({
solid: keep[0].solidSize,
readbacks: tk.length,
traceSteps: +mean('traceSteps').toFixed(2),
prepassSteps: +mean('prepassSteps').toFixed(2),
stepsPerPixel: +mean('stepsPerPixel').toFixed(2),
medianMs: Object.fromEntries(
Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)])
),
});
};
const s = window.__scene.createScene({
canvas: document.getElementById('c'),
unthrottled: true,
fixedLevel: window.__scene.LEVELS[0],
fixedPull: pull,
prepass,
onTraceStats: (t) => {
trace.push(t);
finish();
},
onStats: (st) => {
rows.push(st);
finish();
},
});
if (!s) res({ error: 'createScene returned null' });
}),
{ pull, prepass, FRAMES: +(process.env.FRAMES || 64) }
);
report.runs.push({ viewport: [w, h], pull, prepass, ...r });
}
}
}
}
if (mode === 'diff') {
// Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared
// pixel by pixel. The prepass is meant to change cost, not the picture.
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.setViewportSize({ width: 640, height: 360 });
const shoot = async (pull, prepass, steps) => {
await page.evaluate(
({ pull, prepass, steps }) => {
// Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the
// count stops moving when it is done. Timer-query callbacks lag too far to count on.
window.__raf = 0;
if (!window.__rafPatched) {
window.__rafPatched = true;
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb));
}
window.__d = window.__scene.createScene({
canvas: document.getElementById('c'),
fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps },
fixedPull: pull,
prepass,
});
},
{ pull, prepass, steps }
);
for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) {
const n = await page.evaluate(() => window.__raf);
still = n === last ? still + 1 : 0;
last = n;
}
const png = await page.screenshot();
await page.evaluate(() => window.__d.dispose());
return png.toString('base64');
};
// The step cap makes a grazing ray give up and count as a hit, so the baseline is compared
// with a 512-step render of the same frame as well: whichever is closer to it is the more
// faithful one.
const decode = (x) =>
page.evaluate(
(x) =>
new Promise((r) => {
const i = new Image();
i.onload = () => {
const c = document.createElement('canvas');
c.width = i.width;
c.height = i.height;
const g = c.getContext('2d');
g.drawImage(i, 0, 0);
r(Array.from(g.getImageData(0, 0, i.width, i.height).data));
};
i.src = 'data:image/png;base64,' + x;
}),
x
);
const compare = (da, db) => {
let max = 0, over = 0, sum = 0;
const px = da.length / 4;
for (let i = 0; i < da.length; i += 4) {
let m = 0;
for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k]));
sum += m;
if (m > max) max = m;
if (m > 2) over++;
}
return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) };
};
report.diffs = [];
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
const png = {
off: await shoot(pull, false, 96),
on: await shoot(pull, true, 96),
ref: await shoot(pull, false, 512),
off2: await shoot(pull, false, 96),
};
for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64'));
const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)];
report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) });
}
}
console.log(JSON.stringify(report, null, 1));
await b.close();
})().catch((e) => {
console.log('ERR', String(e).slice(0, 2000));
process.exit(1);
});

View file

@ -1,87 +0,0 @@
// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped.
import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene';
import { LEVELS } from '../../src/app/features/scene/budget';
import { fragmentSource } from '../../src/app/features/scene/shaderSource';
import { WORLD } from '../../src/app/features/scene/world';
(window as unknown as Record<string, unknown>).__scene = {
createScene,
linkStage,
ShaderCompileError,
LEVELS,
fragmentSource,
WORLD,
};
// ---- Phacelle gradient probe -----------------------------------------------------------------
import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw';
import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw';
import { vertexSource } from '../../src/app/features/scene/shaderSource';
const PROBE_FRAG = `#version 300 es
precision highp float; precision highp int;
${cultmath}
${phacelle}
uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span;
in vec2 v_uv; layout(location=0) out vec4 o;
void main() {
// The scene samples the z = 0 slice: random points in a u_span square of it.
vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ;
vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span;
CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm);
o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w);
}`;
/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */
(window as unknown as Record<string, unknown>).__probePhacelle = (
side: number[],
norm: number,
draws: number
) => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2')!;
gl.getExtension('EXT_color_buffer_float');
const mk = (t: number, s: string) => {
const x = gl.createShader(t)!;
gl.shaderSource(x, s);
gl.compileShader(x);
if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!);
return x;
};
const p = gl.createProgram()!;
gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource));
gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG));
gl.linkProgram(p);
gl.useProgram(p);
const N = 1024;
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.viewport(0, 0, N, N);
gl.bindVertexArray(gl.createVertexArray());
gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side);
gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm);
gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400);
const buf = new Float32Array(N * N * 4);
let gradCos = 0,
gradSin = 0,
vMin = 1e9,
vMax = -1e9,
n = 0;
for (let d = 0; d < draws; d++) {
gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf);
for (let i = 0; i < buf.length; i += 4) {
if (buf[i] > gradCos) gradCos = buf[i];
if (buf[i + 1] > gradSin) gradSin = buf[i + 1];
if (buf[i + 2] < vMin) vMin = buf[i + 2];
if (buf[i + 2] > vMax) vMax = buf[i + 2];
n++;
}
}
return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax };
};

View file

@ -4,17 +4,21 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Ratatoskr</title>
<meta name="name" content="Ratatoskr" />
<meta name="author" content="GameCult" />
<title>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<meta
name="description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
<meta
name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<meta property="og:title" content="Ratatoskr" />
<meta property="og:url" content="https://gamecult.org" />
<meta property="og:title" content="Cinny" />
<meta property="og:url" content="https://cinny.in" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
<meta
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
@ -25,8 +29,8 @@
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Ratatoskr" />
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

@ -1,7 +1,7 @@
{
"name": "Ratatoskr",
"short_name": "Ratatoskr",
"description": "GameCult's Matrix client",
"name": "Cinny",
"short_name": "Cinny",
"description": "Yet another matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

View file

@ -0,0 +1,13 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_1961)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
</g>
<defs>
<clipPath id="clip0_2707_1961">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,13 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_2015)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
</g>
<defs>
<clipPath id="clip0_2707_2015">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

19
public/res/svg/cinny.svg Normal file
View file

@ -0,0 +1,19 @@
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
<defs>
</defs>
<g>
<g>
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
</g>
<g>
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
<circle cx="9.5" cy="6.4" r="0.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
<circle cx="52" cy="52" r="8" fill="#45B83B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
<circle cx="52" cy="52" r="8" fill="#989898"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
</svg>

Before

Width:  |  Height:  |  Size: 1,001 B

View file

@ -1,202 +0,0 @@
// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the
// favicon and app-icon PNGs from the same shapes (no image tools needed).
// Run: node scripts/gen-brand.mjs
import { deflateSync } from 'node:zlib';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const BG = '#1c1b22';
// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones.
const squirrel = [
{ t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' },
{ t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' },
{ t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' },
{ t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' },
{
t: 'poly',
pts: [
[35, 19],
[36, 7],
[43, 15],
],
fill: '#c9672a',
},
{
t: 'poly',
pts: [
[44, 15],
[50, 7],
[52, 19],
],
fill: '#c9672a',
},
{ t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' },
{ t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' },
{ t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' },
{ t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' },
{ t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' },
{ t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' },
{ t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' },
];
const badge = (fill) => [
{ t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' },
{ t: 'circle', cx: 52, cy: 52, r: 8, fill },
];
const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }];
const f = (n) => +n.toFixed(2);
const svgOf = (shapes) =>
shapes
.map((s) => {
if (s.t === 'circle')
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="${s.fill}"/>`;
if (s.t === 'rect') return `<rect width="${s.w}" height="${s.h}" fill="${s.fill}"/>`;
if (s.t === 'poly')
return `<polygon points="${s.pts.map((p) => p.join(',')).join(' ')}" fill="${s.fill}"/>`;
return `<ellipse cx="${s.cx}" cy="${s.cy}" rx="${s.rx}" ry="${s.ry}" transform="rotate(${s.rot} ${s.cx} ${s.cy})" fill="${s.fill}"/>`;
})
.join('\n');
const writeSvg = (name, shapes) =>
writeFileSync(
join(root, 'public/res/svg', name),
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">\n${svgOf(
shapes
)}\n</svg>\n`
);
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const inside = (s, x, y) => {
if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h;
if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2;
if (s.t === 'ellipse') {
const a = (-s.rot * Math.PI) / 180;
const dx = x - s.cx;
const dy = y - s.cy;
const u = dx * Math.cos(a) - dy * Math.sin(a);
const v = dx * Math.sin(a) + dy * Math.cos(a);
return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1;
}
let c = false;
for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) {
const [xi, yi] = s.pts[i];
const [xj, yj] = s.pts[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
}
return c;
};
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc = (b) => {
let c = 0xffffffff;
for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const out = Buffer.alloc(12 + data.length);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, 'ascii');
data.copy(out, 8);
out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
};
// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre.
const png = (shapes, size, scale = 1) => {
const rgba = Buffer.alloc(size * size * 4);
const SS = 4;
const colours = shapes.map((s) => hex(s.fill));
for (let py = 0; py < size; py += 1) {
for (let px = 0; px < size; px += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
for (let sx = 0; sx < SS; sx += 1) {
const gx = ((px + (sx + 0.5) / SS) / size) * 64;
const gy = ((py + (sy + 0.5) / SS) / size) * 64;
// the background rect covers the whole canvas and ignores `scale`
let hit = -1;
shapes.forEach((s, i) => {
const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32;
const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32;
if (inside(s, x, y)) hit = i;
});
if (hit >= 0) {
[r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]];
a += 1;
}
}
}
const o = (py * size + px) * 4;
if (a > 0) {
rgba[o] = Math.round(r / a);
rgba[o + 1] = Math.round(g / a);
rgba[o + 2] = Math.round(b / a);
}
rgba[o + 3] = Math.round((a / (SS * SS)) * 255);
}
}
const raw = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y += 1) {
raw[y * (size * 4 + 1)] = 0;
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr.set([8, 6, 0, 0, 0], 8);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
};
// ICO container holding PNG images.
const ico = (images) => {
const head = Buffer.alloc(6 + images.length * 16);
head.writeUInt16LE(1, 2);
head.writeUInt16LE(images.length, 4);
let offset = head.length;
images.forEach(({ size, data }, i) => {
const e = 6 + i * 16;
head[e] = size;
head[e + 1] = size;
head.writeUInt16LE(1, e + 4);
head.writeUInt16LE(32, e + 6);
head.writeUInt32LE(data.length, e + 8);
head.writeUInt32LE(offset, e + 12);
offset += data.length;
});
return Buffer.concat([head, ...images.map((i) => i.data)]);
};
writeSvg('ratatoskr.svg', squirrel);
writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]);
writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]);
const icon = [...background, ...squirrel];
const put = (dir, name, size) => {
mkdirSync(join(root, 'public/res', dir), { recursive: true });
writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82));
};
[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) =>
put('android', `android-chrome-${s}x${s}.png`, s)
);
[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) =>
put('apple', `apple-touch-icon-${s}x${s}.png`, s)
);
writeFileSync(
join(root, 'public/favicon.ico'),
ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) })))
);
console.log('wrote svg logos, favicon.ico, android and apple icons');

View file

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { MatrixError, Room } from 'matrix-js-sdk';
import {
Avatar,
Badge,
@ -31,7 +31,6 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -198,10 +197,8 @@ export const RoomCard = as<'div', RoomCardProps>(
)
);
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
}, [mx, roomIdOrAlias, viaServers])
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;

View file

@ -17,7 +17,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
export type RoomIntroProps = {
room: Room;
@ -44,7 +43,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
const prevRoomId = createContent?.predecessor?.room_id;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');

View file

@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
justifyContent="Center"
>
<Text size="H2" align="Center">
Ratatoskr
Cinny
</Text>
</Box>
</Box>

View file

@ -1,11 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import {
activateEstateRoom,
awaitMyJoin,
joinEstateRoomIdOrAlias,
joinEstateRoomOrThrow,
runJoinCommand,
} from './joinEstateRoom';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
@ -58,19 +52,13 @@ describe('activateEstateRoom', () => {
it('falls back to the error message, then to a generic one', async () => {
const named = recordingClient(
vi
.fn()
.mockRejectedValue(
Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })
)
vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))
);
expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Boom',
});
const bare = recordingClient(
vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })
);
const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 }));
expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Could not join',
@ -80,7 +68,6 @@ describe('activateEstateRoom', () => {
it('reports the server refusal and does not decide access itself', async () => {
const refusal = Object.assign(new Error('M_FORBIDDEN'), {
httpStatus: 403,
errcode: 'M_FORBIDDEN',
data: { error: 'You are not invited to this room.' },
});
const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal));
@ -91,22 +78,13 @@ describe('activateEstateRoom', () => {
});
/** A client holding DM invites as the server sends them: the mark is in the invite event only. */
const dmClient = (opts: {
status?: number;
errcode?: string;
refuse?: boolean;
drop?: boolean;
failWrite?: boolean;
invites: Record<string, string>;
}) => {
const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<string, string> }) => {
let direct: Record<string, string[]> | undefined;
const writes: Record<string, string[]>[] = [];
const joined: string[] = [];
const joinOpts: unknown[] = [];
const mx = {
getSafeUserId: () => '@me:test',
getRoom: (roomId: string) => ({
getMyMembership: () => 'invite',
getMember: () => ({
events: {
member: {
@ -118,29 +96,21 @@ const dmClient = (opts: {
}),
getAccountData: () => (direct ? { getContent: () => direct } : undefined),
setAccountData: async (_t: string, content: Record<string, string[]>) => {
if (opts.failWrite) throw new Error('account data write failed');
await new Promise((r) => {
setTimeout(r, 5);
});
writes.push(structuredClone(content));
direct = structuredClone(content);
},
joinRoom: async (roomId: string, joinArgs?: unknown) => {
joinOpts.push(joinArgs);
joinRoom: async (roomId: string) => {
// the join lands only after m.direct has been written: record what it held
joined.push(JSON.stringify(direct ?? {}));
if (opts.refuse)
throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' });
if (opts.status)
throw Object.assign(new Error('Gateway Time-out'), {
httpStatus: opts.status,
errcode: opts.errcode,
});
if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 });
if (opts.drop) throw new Error('fetch failed');
return { roomId };
},
};
return { mx: mx as never, writes, joined, joinOpts, current: () => direct };
return { mx: mx as never, writes, joined, current: () => direct };
};
const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] });
@ -179,122 +149,6 @@ describe('activateEstateRoom when the join gets no answer', () => {
});
});
describe('activateEstateRoom when the filing cannot be written', () => {
it('reports the failure and sends no join', async () => {
const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result).toEqual({ kind: 'refused', message: 'account data write failed' });
expect(c.joined).toEqual([]);
});
});
describe('activateEstateRoom when a room that is not a direct message refuses', () => {
it('reports the refusal and leaves m.direct alone', async () => {
const joinRoom = vi
.fn()
.mockRejectedValue(
Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })
);
const { mx, calls } = recordingClient(joinRoom);
const result = await activateEstateRoom(mx, room('joinable'));
expect(result.kind).toBe('refused');
expect(calls).toEqual(['joinRoom']);
});
});
describe('activateEstateRoom refusal bounds', () => {
it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => {
const c = dmClient({
status,
errcode: 'M_LIMIT_EXCEEDED',
invites: { '!dm1:test': '@friend:test' },
});
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result.kind).toBe('refused');
expect((result as { message: string }).message).not.toMatch(/tap again to retry/);
expect(c.current()).toEqual({ '@friend:test': [] });
});
it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => {
const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } });
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
});
describe('activateEstateRoom when a gateway answers instead of the server', () => {
it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => {
const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result.kind).toBe('refused');
expect((result as { message: string }).message).toMatch(/tap again to retry/);
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
it('keeps the room listed on a 5xx even when it carries an errcode', async () => {
const c = dmClient({
status: 500,
errcode: 'M_UNKNOWN',
invites: { '!dm1:test': '@friend:test' },
});
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
});
describe('joinEstateRoomIdOrAlias, the room card and /join', () => {
it('files a direct invite opened by its link before the join is sent', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']);
expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' });
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('hands the via servers to the join', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']);
expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]);
});
it('joins an alias or an unheld room without touching m.direct', async () => {
const { mx, calls } = recordingClient();
await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org');
expect(calls).toEqual(['joinRoom']);
});
});
describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => {
it('files a direct invite and answers the joined room id', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test');
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('throws the refusal and unfiles the room', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN');
expect(c.current()).toEqual({ '@friend:test': [] });
});
});
describe('runJoinCommand, /join', () => {
it('reports each refusal and joins the others', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
const reported: string[] = [];
await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m));
expect(reported).toEqual(['M_FORBIDDEN']);
expect(c.joined).toHaveLength(1);
});
it('reports nothing when the join is accepted', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
const reported: string[] = [];
await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m));
expect(reported).toEqual([]);
expect(c.joined).toHaveLength(1);
});
});
describe('awaitMyJoin', () => {
/** A store that gains the room when the test says so, emitting like the SDK. */
const store = () => {
@ -303,12 +157,8 @@ describe('awaitMyJoin', () => {
return {
mx: {
getRoom: () => (membership ? { getMyMembership: () => membership! } : null),
on: (e: string, h: (room: { roomId: string }, m: string) => void) => {
if (e === 'Room.myMembership') handlers.add(h);
},
off: (e: string, h: (room: { roomId: string }, m: string) => void) => {
if (e === 'Room.myMembership') handlers.delete(h);
},
on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h),
off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h),
},
handlers,
become: (roomId: string, m: string) => {

View file

@ -1,11 +1,5 @@
import { EstateRoom } from './buildEstate';
import {
addRoomIdToMDirect,
isRoomAlias,
isRoomId,
removeRoomIdFromMDirect,
} from '../../utils/matrix';
import { splitWithSpace } from '../../utils/common';
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix';
import { isDirectInvite } from '../../utils/room';
export type EstateActivation =
@ -14,7 +8,7 @@ export type EstateActivation =
| { kind: 'refused'; message: string };
const LOST_JOIN_RESPONSE =
'The server did not answer the join. It may have gone through; tap again to retry.';
'The connection dropped before the server answered. The join may have gone through; tap again to retry.';
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
@ -27,31 +21,21 @@ type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
* An invite marked is_direct carries the mark in the invite event the client
* already holds, and the join event does not repeat it. So the room is listed
* in m.direct under the inviter before the join is sent, and taken out again if
* the join is refused by a Matrix error answer (a 4xx carrying an errcode). A
* join that fails any other way (lost response, dropped connection, a gateway's
* 5xx or a status with no errcode) may still have been accepted upstream, so the
* entry stays and the tap is retryable. Nothing needs to recognise the DM after it
* arrives. Every client join goes through here: the estate tap, the inbox Accept,
* the room card, /join, the lobby and space Join buttons, the tombstone and
* predecessor links. One owner for joining.
* the join is refused by a server answer. A join that fails without one (lost
* response, dropped connection) may still have been accepted, so the entry
* stays and the tap is retryable. Nothing needs to recognise the DM after it
* arrives. The inbox Accept goes through here too: one owner for joining.
*/
export const activateEstateRoom = async (
mx: JoinClient,
room: Pick<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
room: Pick<EstateRoom, 'roomId' | 'state' | 'via'>
): Promise<EstateActivation> => {
if (room.state === 'joined') return { kind: 'open', roomId: room.roomId };
const refusal = (e: unknown): EstateActivation => {
const err = e as { data?: { error?: string }; message?: string };
return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' };
};
const refusedByServer = (e: unknown) => {
const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } };
const status = err.httpStatus;
const errcode = err.errcode ?? err.data?.errcode;
return (
typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'
);
};
const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number';
const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null;
const inviter = isDirectInvite(invite, mx.getSafeUserId())
? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender()
@ -65,57 +49,12 @@ export const activateEstateRoom = async (
const joined = await mx.joinRoom(room.roomId, { viaServers: room.via });
return { kind: 'joined', roomId: joined.roomId };
} catch (e) {
if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined);
return refusal(e);
}
};
/**
* activateEstateRoom for a caller that holds only an id or alias (room card,
* /join): a room the client has an invite for is an invited room.
*/
export const joinEstateRoomIdOrAlias = (
mx: JoinClient,
roomIdOrAlias: string,
via?: string[]
): Promise<EstateActivation> =>
activateEstateRoom(mx, {
roomId: roomIdOrAlias,
state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable',
via,
});
/**
* joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a
* useAsyncCallback button): the joined room id, or an Error carrying the refusal.
*/
export const joinEstateRoomOrThrow = async (
mx: JoinClient,
roomIdOrAlias: string,
via?: string[]
): Promise<string> => {
const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via);
if (result.kind === 'refused') throw new Error(result.message);
return result.roomId;
};
/**
* The /join command: every room id or alias in the payload is joined, and each
* refusal is handed to report, never dropped.
*/
export const runJoinCommand = async (
mx: JoinClient,
payload: string,
report: (message: string) => void
): Promise<void> => {
const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a));
const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a)));
results.forEach((r) => {
if (r.kind === 'refused') report(r.message);
});
};
type MembershipSource = {
getRoom(roomId: string): { getMyMembership(): string } | null;
on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown;

View file

@ -19,7 +19,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { JoinRule, Room } from 'matrix-js-sdk';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card';
@ -38,7 +38,6 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
type RoomJoinButtonProps = {
roomId: string;
@ -47,8 +46,8 @@ type RoomJoinButtonProps = {
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<string, Error, []>(
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
@ -61,8 +60,9 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
<Box direction="Column" gap="100">
<Text style={{ wordBreak: 'break-word' }} size="T400">
{joinState.error.message}
{joinState.error.data?.error || joinState.error.message}
</Text>
<Text size="T200">{joinState.error.name}</Text>
</Box>
</Tooltip>
}
@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
size="400"
filled
tabIndex={0}
aria-label={joinState.error.message}
aria-label={joinState.error.data?.error || joinState.error.message}
/>
)}
</TooltipProvider>

View file

@ -18,7 +18,7 @@ import {
} from 'folds';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { Room } from 'matrix-js-sdk';
import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -38,7 +38,6 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { CreateRoomType } from '../../components/create-room/types';
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
function SpaceProfileLoading() {
return (
@ -113,8 +112,8 @@ function UnjoinedSpaceProfile({
}: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<string, Error, []>(
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;

View file

@ -8,7 +8,6 @@ import { Membership } from '../../../types/matrix/room';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getViaServers } from '../../plugins/via-servers';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
@ -19,14 +18,16 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? getViaServers(currentRoom) : [];
return joinEstateRoomOrThrow(mx, replacementRoomId, via);
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateRoom(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data);
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
};
return (

View file

@ -1,79 +0,0 @@
# Background scene
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath
noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back
from the tree on login. It is a self-contained module; nothing in the client imports it yet.
Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts,
`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel
are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure
callbacks and the creature sizing rule. Nothing has drawn a creature.
## Entry points
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's
door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the
feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At
`off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on
demand, so it stays out of the main chunk; if that chunk fails to load the error goes to
`onCompileFail` and the door returns null, it does not throw.
- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns
`{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither
`EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with
only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the
`ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a
baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context
stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to
show. It caps at 30 fps, pauses when
the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the
preference while it runs freezes or thaws it), and steps down a
quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist.
- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and
overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them.
- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`).
- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL
`#define`s are generated from it; no number is written twice.
- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA),
`composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`).
## How it renders
0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per
tile, shrinking each step by the widest any ray of the tile strays from it, so every step is
free for the whole tile. It stores the distance no ray of the tile can have hit before, or
`W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays
there. `createScene({ prepass: false })` skips it (the measurement baseline).
1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of
`min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with
`cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with
coverage and the hit distance (second render target).
2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud
slab marched with a few jittered steps, both only up to the hit distance from pass 1.
3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth,
3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake).
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
tonemap, dither.
`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra
render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited
on), and reports mean steps per pixel. Unset, neither the output nor the target exists.
## The step bound, and what is not proven
The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field,
computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the
envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is
not evaluated.
- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at
|side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof.
The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n).
- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest
terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a
hit proven by the march.
- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects
carry a thin fog halo.
## Measured and not measured
See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They
come from a software rasterizer, not a GPU, and nothing has been run on a phone.

View file

@ -1,443 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene';
import { WORLD } from './world';
import { LEVELS } from './budget';
import { fragmentSource } from './shaderSource';
import { startBackgroundScene } from './index';
type Fn = Mock;
/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */
function fakeGl(failFrom = Infinity, missing: string[] = []) {
let compiles = 0;
const calls: Record<string, Fn> = {};
const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
get(_, prop: string) {
// the two start-target formats must be told apart; every other constant is 1
if (prop === 'RGBA32F') return 32;
if (prop === 'RGBA16F') return 16;
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1;
if (prop === 'getExtension')
return spy(prop, (n: string) => (missing.includes(n) ? null : {}));
if (prop === 'compileShader') {
return spy(prop, () => {
compiles += 1;
});
}
if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom);
if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad');
if (prop === 'getProgramParameter') return spy(prop, () => true);
return spy(prop);
},
});
return { gl: gl as unknown as WebGL2RenderingContext, calls };
}
function fakeCanvas(gl: WebGL2RenderingContext | null) {
const canvas = new EventTarget() as EventTarget & {
clientWidth: number;
clientHeight: number;
width: number;
height: number;
getContext: Fn;
};
canvas.clientWidth = 400;
canvas.clientHeight = 300;
canvas.width = 0;
canvas.height = 0;
canvas.getContext = vi.fn(() => gl);
return canvas;
}
let rafQueue: Array<(t: number) => void>;
let cancelled: number;
let win: EventTarget;
let motion: EventTarget & { matches: boolean };
let doc: EventTarget & { visibilityState: string };
beforeEach(() => {
rafQueue = [];
cancelled = 0;
motion = Object.assign(new EventTarget(), { matches: false });
win = Object.assign(new EventTarget(), {
devicePixelRatio: 1,
matchMedia: () => motion,
});
doc = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('window', win);
vi.stubGlobal('document', doc);
vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb));
vi.stubGlobal('cancelAnimationFrame', () => {
cancelled += 1;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const runFrame = (now: number) => {
const cb = rafQueue.shift();
if (!cb) throw new Error('no frame was scheduled');
cb(now);
};
const asCanvas = (c: ReturnType<typeof fakeCanvas>) => c as unknown as HTMLCanvasElement;
describe('createScene tiers', () => {
it("'off' returns null without asking the canvas for a context", () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
expect(rafQueue).toHaveLength(0);
});
it("'still' draws one frame and schedules no more", () => {
const { gl, calls } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
expect(scene).not.toBeNull();
runFrame(1000);
expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame
expect(rafQueue).toHaveLength(0);
});
it("'still' ignores login(): the pull-back is for a moving scene", () => {
const { gl } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
runFrame(1000);
scene?.login();
expect(rafQueue).toHaveLength(0);
});
it("'full' keeps scheduling frames", () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' });
runFrame(1000);
expect(rafQueue).toHaveLength(1);
});
it("'low' traces with a cheaper level than 'full'", () => {
const traceSteps = (tier: 'low' | 'full') => {
rafQueue.length = 0; // each call is a fresh scene
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier });
runFrame(1000);
// u_maxSteps is the only uniform1i whose value is a trace-step count
return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40);
};
expect(traceSteps('full')).toContain(LEVELS[0].traceSteps);
expect(traceSteps('low')).toContain(LEVELS[2].traceSteps);
expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps);
});
it('returns null when WebGL2 is missing, so the host keeps its ground', () => {
expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull();
});
});
describe('cone prepass', () => {
const programsFor = (prepass?: boolean) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
return calls.createProgram.mock.calls.length;
};
it('is one more program than the baseline, and off means off', () => {
expect(programsFor(true)).toBe(programsFor(false) + 1);
expect(programsFor(undefined)).toBe(programsFor(true));
});
it('draws before pass 1 and only when on', () => {
const drawsFor = (prepass: boolean) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
runFrame(1000);
return calls.drawArrays.mock.calls.length;
};
expect(drawsFor(true)).toBe(drawsFor(false) + 1);
});
it('the trace-step output exists in pass 1 only when the host asked to measure', () => {
expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1');
expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1');
});
it('reads the step bytes tightly packed, whatever the width', () => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} });
runFrame(1000);
// fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call
const packs = calls.pixelStorei.mock.calls.map((c) => c[1]);
expect(packs[0]).toBe(1);
expect(packs).toContain(4);
expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan(
calls.pixelStorei.mock.invocationCallOrder[0]
);
});
it('shares one distance bound between pass 1 and the prepass', () => {
const bound = (stage: 'terrain' | 'prepass') =>
fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0];
expect(bound('prepass')).toBeDefined();
expect(bound('prepass')).toBe(bound('terrain'));
expect(fragmentSource('atmosphere')).not.toContain('sceneDistance');
});
});
describe('device without EXT_color_buffer_float', () => {
const FLOAT = 'EXT_color_buffer_float';
const HALF = 'EXT_color_buffer_half_float';
const build = (missing: string[], extra: Partial<Parameters<typeof createScene>[0]> = {}) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl(Infinity, missing);
const onUnavailable = vi.fn();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra });
if (scene) runFrame(1000); // targets are allocated on the first frame
const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? [];
const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string);
return {
scene,
onUnavailable,
formats,
prepassSource: sources.find((x) => x.includes('o_start')),
};
};
it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => {
const none = vi.fn();
createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none });
expect(none).toHaveBeenCalledTimes(1);
expect(none).toHaveBeenCalledWith('no-webgl2');
const r = build([FLOAT, HALF]);
expect(r.scene).toBeNull();
expect(r.onUnavailable).toHaveBeenCalledTimes(1);
expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer');
expect(build([]).onUnavailable).not.toHaveBeenCalled();
});
it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => {
const r = build([FLOAT]);
expect(r.scene?.startFormat).toBe('RGBA16F');
expect(r.formats).not.toContain(32);
expect(r.prepassSource).toContain('#define START_HALF 1');
expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`);
});
it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => {
const wide = build([HALF]);
expect(wide.scene?.startFormat).toBe('RGBA32F');
expect(wide.formats).toContain(32);
expect(wide.prepassSource).not.toContain('#define START_HALF');
expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F');
});
// The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is
// that rounding. The GPU's rounding of a half write is the unknown a phone must confirm.
const toHalf = (x: number) => {
if (x === 0) return 0;
let e = Math.max(Math.floor(Math.log2(x)), -14);
if (2 ** e > x) e -= 1;
const ulp = 2 ** (e - 10);
const q = x / ulp;
let r = Math.floor(q);
const d = q - r;
if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1;
return r * ulp;
};
const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS));
it('stores a start distance never past the true one, and not much short of it', () => {
let seed = 7;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9];
for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4);
for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t);
for (const t of ts) {
expect(stored(t)).toBeLessThanOrEqual(t);
expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5);
}
});
it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => {
expect(toHalf(WORLD.far)).toBe(WORLD.far);
});
});
describe('shader compile failure', () => {
it('reports through onCompileFail, returns null, and frees the programs already linked', () => {
const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails
const onCompileFail = vi.fn();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail });
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
const err = onCompileFail.mock.calls[0][0] as ShaderCompileError;
expect(err).toBeInstanceOf(ShaderCompileError);
expect(err.stage).toBe('atmosphere');
expect(err.log).toContain('bad');
expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link
expect(rafQueue).toHaveLength(0);
});
it('does not throw out of createScene when no callback is given', () => {
const { gl } = fakeGl(0);
expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow();
});
});
describe('context loss', () => {
it('stops the loop, tells the host, and detaches every listener', () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
const onContextLost = vi.fn();
createScene({ canvas: asCanvas(canvas), onContextLost });
const lost = new Event('webglcontextlost', { cancelable: true });
canvas.dispatchEvent(lost);
expect(lost.defaultPrevented).toBe(true);
expect(onContextLost).toHaveBeenCalledTimes(1);
expect(cancelled).toBe(1);
// listeners are gone: a second loss, a resize and a visibility change do nothing
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
win.dispatchEvent(new Event('resize'));
doc.dispatchEvent(new Event('visibilitychange'));
expect(onContextLost).toHaveBeenCalledTimes(1);
expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run
});
it('does not render a queued frame after the loss', () => {
const { gl, calls } = fakeGl();
const canvas = fakeCanvas(gl);
createScene({ canvas: asCanvas(canvas) });
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
runFrame(1000);
expect(calls.drawArrays).toBeUndefined();
});
});
describe('prefers-reduced-motion', () => {
const runAll = (t0: number) => {
let t = t0;
let n = 0;
while (rafQueue.length && n < 200) {
runFrame(t);
t += 40;
n += 1;
}
return n;
};
it('freezes to a bounded run when the preference turns on mid-flight', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runFrame(1000);
expect(rafQueue).toHaveLength(1); // moving: keeps going
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(runAll(1040)).toBeLessThan(200); // ends
expect(rafQueue).toHaveLength(0);
});
it('thaws a frozen scene when the preference turns off', () => {
motion.matches = true;
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runAll(1000);
expect(rafQueue).toHaveLength(0);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(1);
runFrame(5000);
expect(rafQueue).toHaveLength(1); // unbounded again
});
it('removes the listener on dispose and on context loss', () => {
const { gl } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) });
scene?.dispose();
rafQueue.length = 0;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
const lostCanvas = fakeCanvas(fakeGl().gl);
createScene({ canvas: asCanvas(lostCanvas) });
lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
rafQueue.length = 0;
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
it('a still does not listen: one frame stays one frame', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
runFrame(1000);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
});
describe('startBackgroundScene', () => {
it("does not request a context when the config leaves the tier 'off'", async () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull();
expect(
await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) })
).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
});
it('applies the device ceiling: saveData runs a still, one frame', async () => {
const { gl, calls } = fakeGl();
const scene = await startBackgroundScene(
{ tier: 'full' },
{ saveData: true },
{ canvas: asCanvas(fakeCanvas(gl)) }
);
expect(scene).not.toBeNull();
runFrame(1000);
expect(calls.drawArrays).toHaveBeenCalledTimes(5);
expect(rafQueue).toHaveLength(0);
});
it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => {
vi.resetModules();
vi.doMock('./Scene', () => {
throw new Error('Failed to fetch dynamically imported module');
});
try {
const { startBackgroundScene: start } = await import('./index');
const onCompileFail = vi.fn();
const canvas = fakeCanvas(fakeGl().gl);
const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail });
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error);
expect(canvas.getContext).not.toHaveBeenCalled();
// and with no callback it still does not throw
expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull();
} finally {
vi.doUnmock('./Scene');
vi.resetModules();
}
});
it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => {
const { gl } = fakeGl(0);
const onCompileFail = vi.fn();
const scene = await startBackgroundScene(
{ tier: 'full' },
{},
{ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }
);
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,638 +0,0 @@
import { fragmentSource, vertexSource, Stage } from './shaderSource';
import { cameraPose, pullProgress, CameraPose } from './camera';
import { BudgetLadder, Level, LEVELS } from './budget';
import type { SceneTier } from './tier';
import { PALETTE } from './palette';
/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */
export const GPU_BUDGET_MS = 4.0;
const FRAME_INTERVAL_MS = 1000 / 30;
const REDUCED_MOTION_FRAMES = 32;
const MAX_DPR = 1.5;
export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass';
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass'];
/** Pass 1 pixels per prepass tile edge. */
const TILE = 8;
export interface SceneStats {
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
passMs: Record<PassName, number>;
level: number;
solidSize: [number, number];
atmosphereSize: [number, number];
}
/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */
export interface TraceStats {
/** Mean pass-1 steps per pixel. */
traceSteps: number;
/** Mean prepass steps per tile; 0 without the prepass. */
prepassSteps: number;
/** traceSteps plus the prepass's steps spread over the pixels of each tile. */
stepsPerPixel: number;
}
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
export type SourcePatch = (stage: Stage, source: string) => string;
export class ShaderCompileError extends Error {
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
super(`${stage} shader failed to compile:\n${log}`);
}
}
/**
* Why createScene returned null for want of a device feature. Rendering to half floats needs
* EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with
* the first and RGBA16F with only the second.
*/
export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer';
/**
* The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds
* to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value
* below t, and the bias covers the absolute error where halves go subnormal. The shader reads both
* through #defines, so this is the one copy.
*/
export const HALF_START_SCALE = 1 - 1 / 1024;
export const HALF_START_BIAS = 1 / 32768;
const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`;
/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */
const LOW_START_LEVEL = 2;
/** The 'still' tier draws one frame at this level and stops. */
const STILL_LEVEL = LEVELS[0];
export interface SceneOptions {
canvas: HTMLCanvasElement;
/**
* 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts
* the adaptive ladder low. 'full' starts it at the top. Default 'full'.
*/
tier?: SceneTier;
/** createScene is about to return null because the device lacks a feature, and which one. */
onUnavailable?: (reason: SceneUnavailable) => void;
/** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */
halfStart?: boolean;
/** A shader failed to compile or link. createScene frees what it built and returns null. */
onCompileFail?: (e: ShaderCompileError) => void;
/** The WebGL context was lost. The scene stops for good; the host keeps its ground. */
onContextLost?: () => void;
/** Called per finished frame when timer queries exist. */
onStats?: (s: SceneStats) => void;
/**
* Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it
* compiles pass 1 with a step-count output and an extra render target; unset, neither exists.
*/
onTraceStats?: (s: TraceStats) => void;
/** Weather knob, 0 clear .. 1 overcast. */
coverage?: number;
/** Fixed level instead of the adaptive ladder (measurement). */
fixedLevel?: Level;
/** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */
prepass?: boolean;
/** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */
fixedPull?: number;
/** Ignore the 30 fps cap and the visibility pause (measurement). */
unthrottled?: boolean;
patchSource?: SourcePatch;
}
export interface Scene {
/** Texture format of the prepass start target. */
readonly startFormat: 'RGBA32F' | 'RGBA16F';
/** Start the camera pull-back from the tree. */
login(): void;
dispose(): void;
}
function compile(
gl: WebGL2RenderingContext,
type: number,
source: string,
stage: Stage | 'vertex'
): WebGLShader {
const sh = gl.createShader(type)!;
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) ?? '';
gl.deleteShader(sh);
throw new ShaderCompileError(stage, log);
}
return sh;
}
export function linkStage(
gl: WebGL2RenderingContext,
stage: Stage,
patch?: SourcePatch,
defines?: string
): WebGLProgram {
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
const fs = compile(
gl,
gl.FRAGMENT_SHADER,
patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines),
stage
);
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(prog) ?? '';
gl.deleteProgram(prog);
throw new ShaderCompileError(stage, log);
}
return prog;
}
interface Target {
tex: WebGLTexture;
w: number;
h: number;
}
function halton(i: number, base: number): number {
let f = 1;
let r = 0;
let n = i;
while (n > 0) {
f /= base;
r += f * (n % base);
n = Math.floor(n / base);
}
return r;
}
/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */
export function createScene(opts: SceneOptions): Scene | null {
const { canvas } = opts;
const tier = opts.tier ?? 'full';
if (tier === 'off') return null;
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'low-power',
});
if (!gl) {
opts.onUnavailable?.('no-webgl2');
return null;
}
const wide = !!gl.getExtension('EXT_color_buffer_float');
if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) {
opts.onUnavailable?.('no-float-or-half-float-colour-buffer');
return null;
}
// Every other target is a half; only the prepass start can ask for 32 bits.
const halfStart = opts.halfStart || !wide;
gl.getExtension('OES_texture_float_linear');
const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as {
TIME_ELAPSED_EXT: number;
GPU_DISJOINT_EXT: number;
} | null;
const usePrepass = opts.prepass ?? true;
const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass');
const programs = {} as Record<PassName, WebGLProgram>;
try {
passes.forEach((p) => {
const defines =
p === 'terrain' && opts.onTraceStats
? '#define TRACE_STATS 1\n'
: p === 'prepass' && halfStart
? HALF_START_DEFINES
: undefined;
programs[p] = linkStage(gl, p, opts.patchSource, defines);
});
} catch (e) {
passes.forEach((p) => {
if (programs[p]) gl.deleteProgram(programs[p]);
});
if (!(e instanceof ShaderCompileError)) throw e;
opts.onCompileFail?.(e);
return null;
}
const uniforms = new Map<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
const loc = (p: WebGLProgram, name: string) => {
let m = uniforms.get(p);
if (!m) uniforms.set(p, (m = new Map()));
if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name));
return m.get(name) ?? null;
};
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F;
const makeTarget = (w: number, h: number, format: number, filter: number): Target => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { tex, w, h };
};
const fbos = {
solid: gl.createFramebuffer()!,
pre: gl.createFramebuffer()!,
atm: gl.createFramebuffer()!,
hist: [gl.createFramebuffer()!, gl.createFramebuffer()!],
};
let solid: Target;
let depth: Target;
let steps: Target | undefined;
let startTex: Target;
let atm: Target;
let hist: [Target, Target];
const freeTargets = () => {
[solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => {
if (t) gl.deleteTexture(t.tex);
});
};
const attach = (fbo: WebGLFramebuffer, targets: Target[]) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
targets.forEach((t, i) =>
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0)
);
gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
};
const still = tier === 'still';
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
// Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on.
// Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together.
const READBACK_EVERY = 8;
interface Readback {
fence: WebGLSync;
pixels: number;
tiles: number;
}
let readback: Readback | undefined;
const packSteps = opts.onTraceStats ? gl.createBuffer() : null;
const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null;
const discardReadback = () => {
if (readback) gl.deleteSync(readback.fence);
readback = undefined;
};
const startReadback = () => {
if (!steps || readback) return;
gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2);
gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0);
let tiles = 0;
if (packStart) {
tiles = startTex.w * startTex.h;
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0);
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
gl.pixelStorei(gl.PACK_ALIGNMENT, 4);
readback = {
fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!,
pixels: steps.w * steps.h,
tiles,
};
};
const pollReadback = () => {
if (!readback) return;
const status = gl.clientWaitSync(readback.fence, 0, 0);
if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return;
const { pixels, tiles } = readback;
const bytes = new Uint8Array(pixels);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes);
let preSum = 0;
if (packStart) {
const floats = new Float32Array(tiles * 4);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats);
for (let i = 1; i < floats.length; i += 4) preSum += floats[i];
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
let sum = 0;
for (let i = 0; i < bytes.length; i += 1) sum += bytes[i];
discardReadback();
const traceSteps = sum / pixels;
const prepassSteps = tiles ? preSum / tiles : 0;
opts.onTraceStats?.({
traceSteps,
prepassSteps,
stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels,
});
};
let canvasW = 0;
let canvasH = 0;
let sizedFor: Level | undefined;
let resetHistory = true;
const ensureSize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const w = Math.max(2, Math.round(canvas.clientWidth * dpr));
const h = Math.max(2, Math.round(canvas.clientHeight * dpr));
const lv = level();
if (w === canvasW && h === canvasH && lv === sizedFor) return;
canvasW = w;
canvasH = h;
sizedFor = lv;
canvas.width = w;
canvas.height = h;
freeTargets();
const sw = Math.max(2, Math.round(w * lv.solidScale));
const sh = Math.max(2, Math.round(h * lv.solidScale));
const aw = Math.max(2, Math.round(w * lv.atmosphereScale));
const ah = Math.max(2, Math.round(h * lv.atmosphereScale));
solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR);
depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST);
// Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before.
startTex = usePrepass
? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST)
: makeTarget(1, 1, startFormat, gl.NEAREST);
steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined;
atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR);
hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)];
attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]);
discardReadback();
attach(fbos.pre, [startTex]);
attach(fbos.atm, [atm]);
attach(fbos.hist[0], [hist[0]]);
attach(fbos.hist[1], [hist[1]]);
resetHistory = true;
};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
const start = performance.now();
let loginAt: number | undefined;
let frame = 0;
let histIndex = 0;
let prev: CameraPose | undefined;
let raf = 0;
let lastRendered = -Infinity;
let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this
let disposed = false;
// Timer queries in flight: each holds one pass's query.
const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = [];
const finished = new Map<number, Partial<Record<PassName, number>>>();
const timed = (pass: PassName, f: number, body: () => void) => {
if (!timer || !opts.onStats) {
body();
return;
}
const q = gl.createQuery()!;
gl.beginQuery(timer.TIME_ELAPSED_EXT, q);
body();
gl.endQuery(timer.TIME_ELAPSED_EXT);
pending.push({ q, pass, frame: f });
};
const collect = () => {
if (!timer) return;
while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) {
const { q, pass, frame: f } = pending.shift()!;
const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT);
const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number;
gl.deleteQuery(q);
const row = finished.get(f) ?? {};
if (!disjoint) row[pass] = ns / 1e6;
finished.set(f, row);
if (Object.keys(row).length === passes.length) {
finished.delete(f);
const passMs = { prepass: 0, ...row } as Record<PassName, number>;
const total = passes.reduce((s, p) => s + passMs[p], 0);
if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined;
opts.onStats?.({
passMs,
level: ladder.index,
solidSize: [solid.w, solid.h],
atmosphereSize: [atm.w, atm.h],
});
}
}
};
const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => {
const aspect = canvasW / canvasH;
const th = Math.tan(pose.fov / 2);
gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th);
gl.uniform1f(loc(p, 'u_time'), time);
gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]);
gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]);
gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]);
};
const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.uniform1i(loc(p, name), unit);
};
const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3);
const render = (now: number) => {
ensureSize();
const lv = level();
const frozen = reducedMotion?.matches ?? false;
const time = frozen ? 0 : (now - start) / 1000;
let elapsed: number | undefined;
if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000;
const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time);
const f = frame;
frame += 1;
const jx = halton((f % 16) + 1, 2) - 0.5;
const jy = halton((f % 16) + 1, 3) - 0.5;
// Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays.
if (usePrepass) {
timed('prepass', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
gl.viewport(0, 0, startTex.w, startTex.h);
const p = programs.prepass;
gl.useProgram(p);
setCommon(p, pose, time);
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h);
draw();
});
}
// Pass 1: solid surfaces, writes colour and depth.
timed('terrain', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
gl.viewport(0, 0, solid.w, solid.h);
const p = programs.terrain;
gl.useProgram(p);
setCommon(p, pose, time);
gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]);
gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]);
gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]);
gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]);
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
bindTex(0, startTex, p, 'u_start');
draw();
});
if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost
// Pass 2: atmosphere up to pass 1's depth, low-res.
timed('atmosphere', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.atmosphere;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, depth, p, 'u_depth');
gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps);
gl.uniform1f(loc(p, 'u_frame'), f);
gl.uniform2f(loc(p, 'u_jitter'), jx, jy);
gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h);
gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45);
draw();
});
// TAA resolve into the history that is not being read.
const write = histIndex === 0 ? 1 : 0;
timed('resolve', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.resolve;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, atm, p, 'u_cur');
bindTex(1, hist[histIndex], p, 'u_history');
bindTex(2, depth, p, 'u_depth');
const pp = prev ?? pose;
const th = Math.tan(pp.fov / 2);
gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th);
gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125);
gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h);
draw();
});
// Full-resolution composite to the canvas.
timed('composite', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvasW, canvasH);
const p = programs.composite;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, solid, p, 'u_solid');
bindTex(1, hist[write], p, 'u_atm');
gl.uniform1f(loc(p, 'u_frame'), f);
draw();
});
histIndex = write;
prev = pose;
resetHistory = false;
collect();
pollReadback();
};
const tick = (now: number) => {
raf = 0;
if (disposed) return;
const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2;
if (due) {
if (
!opts.fixedLevel &&
!timer &&
lastRendered > 0 &&
now - lastRendered > FRAME_INTERVAL_MS * 1.5
) {
// no timer: a missed cap counts as over budget
if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined;
}
lastRendered = now;
render(now);
framesLeft -= 1;
}
if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible'))
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (disposed || raf) return;
if (reducedMotion?.matches && framesLeft <= 0) return;
resetHistory = true; // the first frame after a pause has a real elapsed dt
raf = requestAnimationFrame(tick);
};
const undo: Array<() => void> = [];
const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => {
target.addEventListener(type, handler);
undo.push(() => target.removeEventListener(type, handler));
};
const stop = () => {
disposed = true;
if (raf) cancelAnimationFrame(raf);
undo.forEach((f) => f());
};
if (!opts.unthrottled) {
listen(document, 'visibilitychange', () => {
if (document.visibilityState === 'visible') wake();
});
}
listen(window, 'resize', () => wake());
// A lost context takes every texture and program with it and nothing here rebuilds them, so the
// scene ends and the host decides what to show. preventDefault: otherwise the browser never
// restores the context, which is what we want, and a restore would find a dead scene.
listen(canvas, 'webglcontextlost', (e) => {
e.preventDefault();
stop();
opts.onContextLost?.();
});
if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES;
// The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one.
if (reducedMotion && !still) {
listen(reducedMotion, 'change', () => {
framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity;
wake();
});
}
wake();
return {
startFormat: halfStart ? 'RGBA16F' : 'RGBA32F',
login() {
if (still || disposed) return; // a still has no pull-back
loginAt = performance.now();
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
wake();
},
dispose() {
if (disposed) return;
stop();
freeTargets();
passes.forEach((p) => gl.deleteProgram(programs[p]));
discardReadback();
[packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b));
gl.deleteVertexArray(vao);
[fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
},
};
}

View file

@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest';
import { BudgetLadder, LEVELS } from './budget';
describe('budget ladder', () => {
it('steps down after a whole window over budget and never back up', () => {
const ladder = new BudgetLadder(4, 5);
Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false));
expect(ladder.push(9)).toBe(true);
expect(ladder.index).toBe(1);
Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level
expect(ladder.index).toBe(1);
});
it('judges the median, so one slow frame does not step down', () => {
const ladder = new BudgetLadder(4, 5);
[1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms));
expect(ladder.index).toBe(0);
});
it('stops at the last level', () => {
const ladder = new BudgetLadder(4, 1);
Array.from({ length: 20 }).forEach(() => ladder.push(99));
expect(ladder.index).toBe(LEVELS.length - 1);
});
it('every level is cheaper than the one before in each cost axis', () => {
LEVELS.slice(1).forEach((lv, i) => {
const before = LEVELS[i];
expect(lv.solidScale).toBeLessThan(before.solidScale);
expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale);
expect(lv.cloudSteps).toBeLessThan(before.cloudSteps);
expect(lv.traceSteps).toBeLessThan(before.traceSteps);
});
});
it('starts at the level it is given, clamped into the ladder', () => {
expect(new BudgetLadder(4, 5, 2).index).toBe(2);
expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1);
expect(new BudgetLadder(4, 5, -3).index).toBe(0);
});
});

View file

@ -1,59 +0,0 @@
// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up.
export interface Level {
/** Pass 1 resolution as a fraction of the canvas. */
solidScale: number;
/** Pass 2 resolution as a fraction of the canvas. */
atmosphereScale: number;
/** Cloud march steps. */
cloudSteps: number;
/** Sphere-trace step cap. */
traceSteps: number;
}
export const LEVELS: readonly Level[] = [
{ solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 },
{ solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 },
{ solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 },
{ solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 },
];
/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */
export class BudgetLadder {
private level: number;
private recent: number[] = [];
private readonly budgetMs: number;
private readonly window: number;
/** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */
constructor(budgetMs: number, window = 45, startLevel = 0) {
this.budgetMs = budgetMs;
this.window = window;
this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1);
}
get current(): Level {
return LEVELS[this.level];
}
get index(): number {
return this.level;
}
/** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */
push(ms: number): boolean {
this.recent.push(ms);
if (this.recent.length < this.window) return false;
const sorted = [...this.recent].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
this.recent = [];
if (median > this.budgetMs && this.level < LEVELS.length - 1) {
this.level += 1;
return true;
}
return false;
}
}

View file

@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera';
import { WORLD } from './world';
const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]);
describe('login pull-back', () => {
it('is at rest before login and complete after PULL_BACK_SECONDS', () => {
expect(pullProgress(undefined)).toBe(0);
expect(pullProgress(0)).toBe(0);
expect(pullProgress(PULL_BACK_SECONDS)).toBe(1);
expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1);
});
it('only moves away from the tree and never reverses', () => {
let last = -1;
for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) {
const d = dist(cameraPose(pullProgress(s), 0).position);
expect(d).toBeGreaterThanOrEqual(last);
last = d;
}
});
it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => {
for (let t = 0; t < 2000; t += 37) {
expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner);
}
});
it('looks at the tree: forward points at the axis and the basis is orthonormal', () => {
const pose = cameraPose(0.5, 12);
const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis;
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6);
expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6);
expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6);
expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6);
// Forward points back across the tree axis, not away from it.
const [px, , pz] = pose.position;
expect(fx * px + fz * pz).toBeLessThan(0);
});
});

View file

@ -1,56 +0,0 @@
// The scene's camera: close under the tree at rest, pulled back on login, drifting after.
// Pure functions of time so the pull-back is testable without a GL context.
export type Vec3 = readonly [number, number, number];
export interface CameraPose {
position: Vec3;
/** Column-major right, up, forward: the layout of the shader's mat3 uniform. */
basis: readonly [number, number, number, number, number, number, number, number, number];
/** Vertical field of view in radians. */
fov: number;
}
export const PULL_BACK_SECONDS = 6;
// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground.
const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 };
const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 };
const DRIFT_RAD_PER_SEC = 0.025;
const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */
export function pullProgress(elapsed: number | undefined): number {
if (elapsed === undefined || elapsed <= 0) return 0;
return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS));
}
function norm(v: Vec3): Vec3 {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
}
function cross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
/**
* @param pull 0..1 from pullProgress
* @param time seconds since the scene started, for the slow orbit
*/
export function cameraPose(pull: number, time: number): CameraPose {
const radius = lerp(NEAR.radius, FAR.radius, pull);
const camY = lerp(NEAR.camY, FAR.camY, pull);
const targetY = lerp(NEAR.targetY, FAR.targetY, pull);
const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC;
const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)];
const forward = norm([-position[0], targetY - position[1], -position[2]]);
const right = norm(cross([0, 1, 0], forward));
const up = cross(forward, right);
return {
position,
basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'],
fov: lerp(NEAR.fov, FAR.fov, pull),
};
}

View file

@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures';
const buckets: SizeBucket[] = ['small', 'medium', 'large'];
describe('creature scale', () => {
it('follows sizeBucket for a creature with an estate', () => {
buckets.forEach((sizeBucket) =>
expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket])
);
});
it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => {
expect(creatureScale({ neutral: true })).toBeNull();
buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull());
});
it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => {
expect(creatureScale({ neutral: false })).toBeNull();
expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull();
});
it('draws nothing for an unknown bucket, including inherited property names', () => {
expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull();
expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull();
});
});

View file

@ -1,37 +0,0 @@
// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut.
//
// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection).
// Nothing here is binding until Bifrost's steward cuts that contract.
//
// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false.
// Invariants:
// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket.
// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is
// ignored, and a neutral creature is not drawn.
// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted.
// - activity comes from the Heimdall session.
// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets.
// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed.
export type SizeBucket = 'small' | 'medium' | 'large';
export interface Creature {
id: string;
estate: string;
/** Absent when neutral. Read it only through creatureScale. */
sizeBucket?: SizeBucket;
neutral: boolean;
activity: 'idle' | 'active';
}
export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
small: 0.7,
medium: 1,
large: 1.4,
};
/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */
export function creatureScale(c: Pick<Creature, 'sizeBucket' | 'neutral'>): number | null {
if (c.neutral || c.sizeBucket === undefined) return null;
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null;
}

View file

@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest';
import { trimGlsl } from './cultmathTrim';
import { fragmentSource, Stage } from './shaderSource';
import cultmath from './shaders/CultMath.glsl?raw';
const LIB = `// header
#ifndef LIB_GLSL
#define LIB_GLSL
const float LIB_PI = 3.14;
float lib_half(float v) { return v * 0.5; }
vec2 lib_half(vec2 v) { return v * 0.5; }
// uses the constant and an overload set
float lib_pi_half(float v) { return lib_half(v) * LIB_PI; }
struct LibPair { float a; float b; };
LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; }
float lib_unused(float v) { return v; }
#endif
`;
describe('trimGlsl', () => {
it('keeps what a root names and what that reaches, and drops the rest', () => {
const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']);
expect(out).toContain('lib_pi_half');
expect(out).toContain('LIB_PI');
expect(out).toContain('float lib_half');
expect(out).toContain('vec2 lib_half'); // overloads stay together
expect(out).not.toContain('lib_unused');
expect(out).not.toContain('LibPair');
expect(out).not.toContain('lib_pair');
});
it('keeps a struct that a kept function returns', () => {
const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']);
expect(out).toContain('struct LibPair');
expect(out).toContain('lib_half');
expect(out).not.toContain('lib_pi_half');
});
it('keeps the include guard balanced', () => {
const out = trimGlsl(LIB, ['lib_half(1.0)']);
expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/);
expect(out.trimEnd().endsWith('#endif')).toBe(true);
});
it('keeps nothing but the guard for a root that names nothing', () => {
const out = trimGlsl(LIB, ['void main() {}']);
expect(out).not.toContain('lib_');
expect(out).toContain('#ifndef LIB_GLSL');
});
});
describe('CultMath per shader stage', () => {
const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []);
const defined = (src: string, name: string) =>
new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src);
it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => {
const src = fragmentSource(stage);
cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true));
});
it.each(stages)('%s is smaller than the whole library', (stage) => {
expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2);
});
it('puts the noise a stage calls in that stage and not in the others', () => {
expect(fragmentSource('terrain')).toContain('cultmath_snoise');
expect(fragmentSource('composite')).not.toContain('cultmath_snoise');
});
it('adds Phacelle to terrain alone, as its own file', () => {
expect(fragmentSource('terrain')).toContain('cultmath_phacelle');
stages
.filter((s) => s !== 'terrain')
.forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle'));
});
});

View file

@ -1,81 +0,0 @@
/**
* Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never
* edited; each stage compiles only the declarations its own source (and the source concatenated
* beside it) names, directly or through other kept declarations. Overloads share a name and are
* kept together.
*/
interface Decl {
name: string;
text: string;
}
const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m;
const GUARD_CLOSE = /\n#endif\s*$/;
/** Name a top-level declaration defines: function, struct or const. */
function declName(text: string): string | undefined {
const code = text.replace(/\/\/.*$/gm, '');
if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1];
return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1];
}
/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */
function declarations(body: string): Decl[] {
const out: Decl[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '/' && body[i + 1] === '/') {
const eol = body.indexOf('\n', i);
i = eol < 0 ? body.length : eol;
} else if (c === '{') {
depth += 1;
} else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) {
if (c === '}') depth -= 1;
// a struct's closing brace is followed by `;`
const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i;
const text = body.slice(start, end + 1);
const name = declName(text);
if (name) out.push({ name, text });
start = end + 1;
i = end;
} else if (c === '}') {
depth -= 1;
}
i += 1;
}
return out;
}
const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? [];
/** `library` trimmed to what `roots` reach; the include guard is kept. */
export function trimGlsl(library: string, roots: string[]): string {
const open = GUARD_OPEN.exec(library)?.[0] ?? '';
const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : '';
const body = library
.slice(open ? library.indexOf(open) + open.length : 0)
.replace(GUARD_CLOSE, '');
const decls = declarations(body);
const byName = new Map<string, Decl[]>();
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
const kept = new Set<Decl>();
const queue = roots.flatMap(idents);
while (queue.length) {
const group = byName.get(queue.pop() ?? '');
group?.forEach((d) => {
if (kept.has(d)) return;
kept.add(d);
queue.push(...idents(d.text));
});
}
const text = decls
.filter((d) => kept.has(d))
.map((d) => d.text.replace(/^\s*\n/, ''))
.join('\n');
return `${open}${text}${close}`;
}

View file

@ -1,33 +0,0 @@
import type { Scene, SceneOptions } from './Scene';
import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier';
export type { Scene, SceneStats } from './Scene';
export type { SceneTier } from './tier';
export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */
onCompileFail?: (e: Error) => void;
};
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
* main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline,
* stale deploy): the failure goes to onCompileFail and nothing throws.
*/
export async function startBackgroundScene(
config: BackgroundSceneConfig | undefined,
capabilities: Capabilities,
options: StartOptions
): Promise<Scene | null> {
const tier = resolveTier(config, capabilities);
if (tier === 'off') return null;
let createScene: typeof import('./Scene').createScene;
try {
({ createScene } = await import('./Scene'));
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
return null;
}
return createScene({ ...options, tier });
}

View file

@ -1,27 +0,0 @@
// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs
// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the
// background reads as the same dusk as gamecult.org. The shaders hold no colour literal.
export type Rgb = readonly [number, number, number];
const srgb = (r: number, g: number, b: number): Rgb => [
(r / 255) ** 2.2,
(g / 255) ** 2.2,
(b / 255) ** 2.2,
];
export const PALETTE = {
sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb,
skyTop: srgb(30, 36, 92),
skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb,
fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb,
grass: srgb(72, 112, 86),
rock: srgb(112, 104, 122),
bark: srgb(62, 46, 40),
leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb,
sunDir: (() => {
const v = [0.55, 0.16, -0.82];
const l = Math.hypot(v[0], v[1], v[2]);
return [v[0] / l, v[1] / l, v[2] / l] as Rgb;
})(),
} as const;

View file

@ -1,41 +0,0 @@
import cultmath from './shaders/CultMath.glsl?raw';
import phacelle from './shaders/CultMath.Phacelle.glsl?raw';
import common from './shaders/common.glsl?raw';
import fullscreenVert from './shaders/fullscreen.vert.glsl?raw';
import sdf from './shaders/sdf.glsl?raw';
import prepassFrag from './shaders/prepass.frag.glsl?raw';
import terrainFrag from './shaders/terrain.frag.glsl?raw';
import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw';
import resolveFrag from './shaders/resolve.frag.glsl?raw';
import compositeFrag from './shaders/composite.frag.glsl?raw';
import { WORLD, worldDefines } from './world';
import { trimGlsl } from './cultmathTrim';
export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite';
const HEADER =
'#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n';
export const vertexSource = HEADER + fullscreenVert;
const stages: Record<Stage, string> = {
prepass: prepassFrag,
terrain: terrainFrag,
atmosphere: atmosphereFrag,
resolve: resolveFrag,
composite: compositeFrag,
};
/**
* Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the
* shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define`
* lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where
* it is called.
*/
export function fragmentSource(stage: Stage, defines = ''): string {
const solid = stage === 'terrain' || stage === 'prepass';
const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : '';
const world = solid ? sdf : '';
const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]);
return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`;
}

View file

@ -1,99 +0,0 @@
// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it.
#ifndef CULTMATH_PHACELLE_GLSL
#define CULTMATH_PHACELLE_GLSL
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
vec4 cos;
vec4 sin;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
// the C# body operation for operation; the C#-only internal overload that switches the prune off
// has no shader twin.
CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization)
{
vec3 cell = floor(p);
vec3 local = p - cell;
float phaseOffset = offset * CULTMATH_TAU;
float sumW = 0.0;
float sumCos = 0.0;
float sumSin = 0.0;
vec3 gradW = vec3(0.0, 0.0, 0.0);
vec3 gradCos = vec3(0.0, 0.0, 0.0);
vec3 gradSin = vec3(0.0, 0.0, 0.0);
for (int dz = -1; dz <= 2; dz++)
{
float gz = max(0.0, abs(local.z - float(dz)) - 0.5);
for (int dy = -1; dy <= 2; dy++)
{
float gy = max(0.0, abs(local.y - float(dy)) - 0.5);
for (int dx = -1; dx <= 2; dx++)
{
float gx = max(0.0, abs(local.x - float(dx)) - 0.5);
if (gx * gx + gy * gy + gz * gz >= 2.25)
continue;
vec3 gridStep = vec3(dx, dy, dz);
ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep));
vec3 jitter = vec3(
float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5,
float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5,
float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5);
vec3 v = local - gridStep - jitter;
float falloff = exp(-2.0 * dot(v, v));
float w = max(0.0, falloff - 0.01111);
vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0);
float phase = dot(v, side) + phaseOffset;
float c = cos(phase);
float s = sin(phase);
sumW += w;
sumCos += w * c;
sumSin += w * s;
gradW += dw;
gradCos += dw * c - (w * s) * side;
gradSin += dw * s + (w * c) * side;
}
}
}
float invW = 1.0 / sumW;
float rawCos = sumCos * invW;
float rawSin = sumSin * invW;
vec3 gradRawCos = (gradCos - rawCos * gradW) * invW;
vec3 gradRawSin = (gradSin - rawSin * gradW) * invW;
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
float floorLength = 1.0 - normalization;
CultPhasor result;
if (rawLength > floorLength)
{
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
# Vendored CultMath GLSL
`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2
lowering of `packages/cultmath/shaders/CultMath.hlsl` and
`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch
`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries
the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch
does not carry the GLSL yet). Never edit them here: regenerate in CultLib and
re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune
Skovbo Johansen's Phacelle noise, see its header), so that file keeps its
header and any change to it is published under MPL-2.0.
The other `*.glsl` files here are the scene's own stages. They are
concatenated by `../shaderSource.ts` after a prelude; they use no colour
literal (the palette is uniforms) and define no noise.
`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches
(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage
alone.

View file

@ -1,90 +0,0 @@
// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud
// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance.
uniform sampler2D u_depth; // pass 1 hit distance, nearest
uniform int u_steps; // cloud march steps
uniform float u_frame;
uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target
uniform vec2 u_size; // this target's size in pixels
uniform float u_coverage; // weather knob: 0 clear .. 1 overcast
in vec2 v_uv;
layout(location = 0) out vec4 o_atm;
float hg(float cosTheta, float g)
{
float g2 = g * g;
return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
}
float cloudDensity(vec3 p)
{
float wind = u_time * 2.5;
float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w;
float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0);
float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h);
return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile;
}
void main()
{
vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size;
vec3 rd = camRay(jittered);
vec3 ro = u_camPos;
float depth = texture(u_depth, v_uv).r;
float tEnd = min(depth, W_ATM_FAR);
// Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray.
float k = W_FOG_FALLOFF;
float dy = rd.y * tEnd;
float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd);
float fogT = exp(-max(tau, 0.0));
float cosSun = dot(rd, u_sunDir);
vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9;
vec3 inscatter = fogLight * (1.0 - fogT);
float trans = fogT;
// Clouds: only the part of the ray inside the slab and in front of the surface.
float ta = 0.0;
float tb = tEnd;
if (abs(rd.y) > 1e-4)
{
float t0 = (W_CLOUD_Y0 - ro.y) / rd.y;
float t1 = (W_CLOUD_Y1 - ro.y) / rd.y;
ta = max(0.0, min(t0, t1));
tb = min(tEnd, max(t0, t1));
}
else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1)
tb = 0.0;
if (tb > ta && u_steps > 0)
{
float dt = (tb - ta) / float(u_steps);
float t = ta + dt * ign(gl_FragCoord.xy, u_frame);
float T = 1.0;
vec3 L = vec3(0.0);
float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2);
for (int i = 0; i < u_steps; i++)
{
vec3 p = ro + rd * t;
float d = cloudDensity(p);
if (d > 0.01)
{
// One sample toward the sun stands in for self-shadowing.
float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2);
vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35;
float ext = d * dt * 0.035;
float stepT = exp(-ext);
L += T * (1.0 - stepT) * light;
T *= stepT;
if (T < 0.03)
break;
}
t += dt;
}
// Fog in front of the slab is already in `inscatter`; clouds sit behind it.
inscatter += trans * L;
trans *= T;
}
o_atm = vec4(inscatter, trans);
}

View file

@ -1,31 +0,0 @@
// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines.
uniform vec3 u_camPos;
uniform mat3 u_camBasis; // columns: right, up, forward
uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2)
uniform float u_time;
uniform vec3 u_sunDir; // unit, towards the sun
uniform vec3 u_sunColor;
uniform vec3 u_skyTop;
uniform vec3 u_skyHorizon;
uniform vec3 u_fogColor;
vec3 camRay(vec2 uv)
{
vec2 ndc = uv * 2.0 - 1.0;
return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0));
}
float ign(vec2 p, float frame)
{
p += 5.588238 * mod(frame, 64.0);
return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
}
vec3 skyColor(vec3 rd)
{
float h = clamp(rd.y, 0.0, 1.0);
vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45));
float s = max(dot(rd, u_sunDir), 0.0);
c += u_sunColor * (1.5 * pow(s, 256.0));
return c;
}

View file

@ -1,26 +0,0 @@
// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere.
uniform sampler2D u_solid; // rgb lit, a coverage (bilinear)
uniform sampler2D u_atm; // TAA'd atmosphere (bilinear)
uniform float u_frame;
in vec2 v_uv;
out vec4 o_frag;
vec3 tonemap(vec3 x)
{
// Narkowicz ACES fit.
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}
void main()
{
vec3 rd = camRay(v_uv);
vec4 solid = texture(u_solid, v_uv);
vec4 atm = texture(u_atm, v_uv);
vec3 base = mix(skyColor(rd), solid.rgb, solid.a);
vec3 col = base * atm.a + atm.rgb;
col = tonemap(col);
col = pow(col, vec3(1.0 / 2.2));
col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0;
o_frag = vec4(col, 1.0);
}

View file

@ -1,8 +0,0 @@
out vec2 v_uv;
void main()
{
// One oversized triangle; uv spans 0..1 over the visible part.
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
v_uv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}

View file

@ -1,57 +0,0 @@
// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's
// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the
// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any
// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball
// around each ray's own point.
uniform int u_maxSteps;
uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it
in vec2 v_uv;
layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both)
void main()
{
vec3 ro = u_camPos;
vec3 rd = camRay(v_uv);
// Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit
// directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so
// the maximum sits at a corner. The margin is for float rounding.
float spread = 0.0;
for (int c = 0; c < 4; c++)
{
vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0;
spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd));
}
spread *= 1.05;
// Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR):
// none can hit anything.
float rise = rd.y - spread;
float tEnd = W_FAR;
if (rise > 0.0)
tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise));
float t = 0.0;
float leafness;
bool onTree;
int evals = 0;
for (int i = 0; i < u_maxSteps && t < tEnd; i++)
{
evals++;
// Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so
// no ray of the tile starts inside its own hit radius.
float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006;
if (s <= 0.0)
break;
t += s;
}
float start = t;
#ifdef START_HALF
// The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past
// the true one is a ray that could skip a surface, so shave more than half a ulp off first
// (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds.
start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS);
#endif
o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0);
}

View file

@ -1,41 +0,0 @@
// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the
// current frame's 3x3 neighbourhood, blend.
uniform sampler2D u_cur;
uniform sampler2D u_history;
uniform sampler2D u_depth;
uniform vec3 u_prevPos;
uniform mat3 u_prevBasis;
uniform vec2 u_prevTanHalf;
uniform float u_blend; // weight of the current frame; 1 resets
uniform vec2 u_curSize;
in vec2 v_uv;
layout(location = 0) out vec4 o_hist;
void main()
{
vec2 px = 1.0 / u_curSize;
vec4 cur = texture(u_cur, v_uv);
vec4 lo = cur;
vec4 hi = cur;
for (int y = -1; y <= 1; y++)
for (int x = -1; x <= 1; x++)
{
vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px);
lo = min(lo, s);
hi = max(hi, s);
}
// Where this pixel's ray ended, reprojected into last frame's camera.
vec3 rd = camRay(v_uv);
float t = min(texture(u_depth, v_uv).r, 600.0);
vec3 v = u_camPos + rd * t - u_prevPos;
vec3 local = transpose(u_prevBasis) * v;
vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0);
vec2 prevUv = prevNdc * 0.5 + 0.5;
bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0)));
float a = (inside && u_blend < 1.0) ? u_blend : 1.0;
vec4 hist = clamp(texture(u_history, prevUv), lo, hi);
o_hist = mix(hist, cur, a);
}

View file

@ -1,95 +0,0 @@
// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the
// cone prepass, so both step by the same bound.
// ---- terrain -------------------------------------------------------------------------------
float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); }
// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap.
float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }
#if W_ERODE_ENABLED
CultPhasor terrainPhacelle(vec2 xz)
{
// A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion.
return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM);
}
#endif
// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the
// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated.
float terrainDistance(vec3 p)
{
float m = terrainMask(p.xz);
// Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most
// of their steps here.
if (m <= 0.0)
return p.y * W_SLOPE_ENV_INV;
float hb = m * W_AMP * terrainFbm(p.xz);
float above = p.y - (hb + m * W_ERODE_AMP);
if (above > 0.0)
return above * W_SLOPE_ENV_INV;
#if W_ERODE_ENABLED
hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w;
#endif
return (p.y - hb) * W_SLOPE_FULL_INV;
}
vec3 terrainNormal(vec2 xz)
{
float r = length(xz);
float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r);
float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0);
float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0;
vec2 dir = r > 0.0 ? xz / r : vec2(0.0);
vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN);
float f = fg.w;
vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir;
#if W_ERODE_ENABLED
CultPhasor ph = terrainPhacelle(xz);
g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir;
#endif
return normalize(vec3(-g.x, 1.0, -g.y));
}
// ---- world tree ----------------------------------------------------------------------------
float smin(float a, float b, float k)
{
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz.
float treeDistance(vec3 p, out float leafness)
{
vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0);
float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y);
leafness = 0.0;
if (bound > 8.0)
return bound;
float ang = atan(p.z, p.x);
float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0);
float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15));
float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7;
vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R;
float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0));
float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp;
canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L)
float d = smin(trunk, canopy, 6.0);
leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0);
return d;
}
// ---- trace ---------------------------------------------------------------------------------
float sceneDistance(vec3 p, out float leafness, out bool onTree)
{
float dt = terrainDistance(p);
float dr = treeDistance(p, leafness);
onTree = dr < dt;
return min(dt, dr);
}

View file

@ -1,108 +0,0 @@
// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage
// and, separately, the hit distance that bounds pass 2.
uniform vec3 u_grass;
uniform vec3 u_rock;
uniform vec3 u_bark;
uniform vec3 u_leaf;
uniform int u_maxSteps;
uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses
in vec2 v_uv;
layout(location = 0) out vec4 o_color; // rgb lit, a coverage
layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss
#ifdef TRACE_STATS
layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only)
#endif
// ---- trace ---------------------------------------------------------------------------------
void main()
{
vec3 ro = u_camPos;
vec3 rd = camRay(v_uv);
float t = texture(u_start, v_uv).r;
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the
// prepass proved empty arrives as W_FAR.
bool missAtOnce = t >= W_FAR;
if (ro.y > W_MAX_HEIGHT)
{
missAtOnce = missAtOnce || rd.y >= 0.0;
t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y);
}
if (missAtOnce)
{
o_color = vec4(0.0);
o_depth = W_FAR;
#ifdef TRACE_STATS
o_steps = 0.0;
#endif
return;
}
bool hit = false;
bool onTree = false;
float leafness = 0.0;
int i = 0;
int evals = 0;
for (; i < u_maxSteps; i++)
{
evals++;
vec3 p = ro + rd * t;
float d = sceneDistance(p, leafness, onTree);
if (d < 0.0006 * t + 0.01)
{
hit = true;
break;
}
t += d;
if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0))
break;
}
// The conservative bound makes grazing rays crawl: a ray that ran out of steps while still
// below the highest terrain is a distant surface under thick fog, so it ends there.
if (!hit && i == u_maxSteps && t < W_FAR)
{
hit = true;
sceneDistance(ro + rd * t, leafness, onTree);
}
#ifdef TRACE_STATS
o_steps = float(evals) / 255.0;
#endif
if (!hit)
{
o_color = vec4(0.0);
o_depth = W_FAR;
return;
}
vec3 p = ro + rd * t;
vec3 n;
vec3 albedo;
if (onTree)
{
float e = 0.35;
float l;
n = normalize(vec3(
treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l),
treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l),
treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l)));
float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8);
albedo = mix(u_bark, u_leaf * speckle, leafness);
}
else
{
n = terrainNormal(p.xz);
float rockness = smoothstep(0.78, 0.55, n.y);
albedo = mix(u_grass, u_rock, rockness);
}
float ndl = dot(n, u_sunDir);
float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass
vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45;
vec3 col = albedo * (u_sunColor * wrap * wrap + ambient);
o_color = vec4(col, 1.0);
o_depth = t;
}

View file

@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveTier } from './tier';
describe('scene tier', () => {
it('is off unless the config turns it on, which is the feature flag', () => {
expect(resolveTier(undefined)).toBe('off');
expect(resolveTier({})).toBe('off');
expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off');
expect(resolveTier({ tier: 'full' })).toBe('full');
});
it('lets the device lower the configured tier and never raise it', () => {
expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still');
expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low');
expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low');
expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still');
expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe(
'still'
);
expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low');
});
it('treats unknown capabilities as capable', () => {
expect(resolveTier({ tier: 'full' }, {})).toBe('full');
});
});

View file

@ -1,49 +0,0 @@
// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both
// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the
// device can only lower it.
export type SceneTier = 'off' | 'still' | 'low' | 'full';
/** Cheapest first. */
export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full'];
export interface Capabilities {
/** `navigator.connection.saveData`. */
saveData?: boolean;
/** `navigator.deviceMemory` in GiB (Chromium only). */
deviceMemory?: number;
hardwareConcurrency?: number;
}
export interface BackgroundSceneConfig {
tier?: SceneTier;
}
export function readCapabilities(nav: Navigator): Capabilities {
const n = nav as Navigator & {
deviceMemory?: number;
connection?: { saveData?: boolean };
};
return {
saveData: n.connection?.saveData,
deviceMemory: n.deviceMemory,
hardwareConcurrency: n.hardwareConcurrency,
};
}
/** The most the device is allowed to run, before the configured ceiling is applied. */
function deviceCeiling(c: Capabilities): SceneTier {
if (c.saveData) return 'still';
if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low';
return 'full';
}
/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */
export function resolveTier(
config: BackgroundSceneConfig | undefined,
capabilities: Capabilities = {}
): SceneTier {
const configured = config?.tier ?? 'off';
const ceiling = deviceCeiling(capabilities);
return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling;
}

View file

@ -1,40 +0,0 @@
import { describe, expect, it } from 'vitest';
import common from './shaders/common.glsl?raw';
import terrain from './shaders/terrain.frag.glsl?raw';
import atmosphere from './shaders/atmosphere.frag.glsl?raw';
import resolve from './shaders/resolve.frag.glsl?raw';
import composite from './shaders/composite.frag.glsl?raw';
import { terrainBounds, WORLD, worldDefines } from './world';
describe('world defines', () => {
it('define every W_ name any scene shader uses', () => {
const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1]));
[common, terrain, atmosphere, resolve, composite]
.flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)])
.forEach((m) => expect(defined, m[0]).toContain(m[0]));
});
it('emit only valid GLSL numbers', () => {
expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/);
});
});
describe('terrain step bound', () => {
it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => {
const b = terrainBounds();
expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope);
const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 });
expect(off.slopeFull).toBe(off.slopeEnvelope);
});
it('puts every surface under the height the tracer skips to', () => {
const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]);
expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight);
expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]);
});
it('leaves the erosion bound turned on only when a bound exists', () => {
expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1');
expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0');
});
});

View file

@ -1,117 +0,0 @@
// The scene's world constants. One owner: the shader prelude below is generated from this
// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the
// sphere-trace step safe are computed beside the numbers they bound.
// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored
// CultMath.glsl (provenance in CultLib math.Interval.cs).
export const SNOISE_LIPSCHITZ = 7.9640074;
export const WORLD = {
far: 1400,
// Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)).
amp: 70,
freq: 1 / 520,
octaves: 4,
lacunarity: 2.0,
gain: 0.45,
// Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login
// camera stays inside plateauInner, so it can never end up inside the terrain.
plateauInner: 135,
plateauOuter: 380,
erodeAmp: 2.5,
erodeFreq: 1 / 38,
// The stripe wave vector and normalization the terrain feeds cultmath_phacelle.
erodeSide: [1.7, 0, 1] as const,
erodeNorm: 0.5,
// Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a
// sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin.
// The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc
// envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero,
// divide this bound by (1 - n).
// |value| <= 1 holds by construction (the output is a normalized phasor).
// Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }.
phacelleGradBound: 7.0 as number,
// World tree at the origin.
tree: {
trunkHeight: 38,
baseRadius: 3.4,
topRadius: 1.9,
canopyY: 52,
canopyRadius: [30, 17, 30] as const,
},
// Atmosphere.
atmosphereFar: 1400,
cloudBase: 130,
cloudTop: 230,
fogDensity: 0.0028,
fogFalloff: 0.011,
} as const;
const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */
function slopeSum(octaves: number, lacunarity: number, gain: number): number {
let s = 0;
for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i;
return s;
}
/** Sum over octaves of gain^i: the fbm value bound. */
function valueSum(octaves: number, gain: number): number {
let s = 0;
for (let i = 0; i < octaves; i += 1) s += gain ** i;
return s;
}
export interface TerrainBounds {
/** Lipschitz bound of the envelope surface (no erosion). */
slopeEnvelope: number;
/** Lipschitz bound of the eroded surface. */
slopeFull: number;
/** Highest point the terrain can reach. */
maxHeight: number;
}
export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds {
const fbmMax = valueSum(w.octaves, w.gain);
const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope
const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain);
const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope;
const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound;
return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp };
}
/** The #defines every stage sees. */
export function worldDefines(w: typeof WORLD = WORLD): string {
const b = terrainBounds(w);
const t = w.tree;
const lines: Array<[string, string]> = [
['W_FAR', f(w.far)],
['W_AMP', f(w.amp)],
['W_FREQ', f(w.freq)],
['W_OCTAVES', String(w.octaves)],
['W_LACUNARITY', f(w.lacunarity)],
['W_GAIN', f(w.gain)],
['W_PLATEAU_IN', f(w.plateauInner)],
['W_PLATEAU_OUT', f(w.plateauOuter)],
['W_ERODE_AMP', f(w.erodeAmp)],
['W_ERODE_FREQ', f(w.erodeFreq)],
['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`],
['W_ERODE_NORM', f(w.erodeNorm)],
['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'],
['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))],
['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))],
['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))],
['W_TRUNK_H', f(t.trunkHeight)],
['W_TRUNK_R0', f(t.baseRadius)],
['W_TRUNK_R1', f(t.topRadius)],
['W_CANOPY_Y', f(t.canopyY)],
['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`],
['W_ATM_FAR', f(w.atmosphereFar)],
['W_CLOUD_Y0', f(w.cloudBase)],
['W_CLOUD_Y1', f(w.cloudTop)],
['W_FOG_DENSITY', f(w.fogDensity)],
['W_FOG_FALLOFF', f(w.fogFalloff)],
];
return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n');
}

View file

@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
<Box shrink="No">
<img
style={{ width: toRem(60), height: toRem(60) }}
src={RatatoskrSVG}
alt="Ratatoskr logo"
src={CinnySVG}
alt="Cinny logo"
/>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Ratatoskr</Text>
<Text size="H3">Cinny</Text>
<Text size="T200">v4.12.8</Text>
</Box>
<Text>GameCult&apos;s Matrix client.</Text>
<Text>Yet another matrix client.</Text>
</Box>
<Box gap="200" wrap="Wrap">
<Button
as="a"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
href="https://github.com/cinnyapp/cinny"
rel="noreferrer noopener"
target="_blank"
variant="Secondary"
@ -65,19 +65,20 @@ export function About({ requestClose }: AboutProps) {
>
<Text size="B300">Source Code</Text>
</Button>
</Box>
<Text size="T300">
Based on{' '}
<a
href="https://github.com/cinnyapp/cinny"
target="_blank"
<Button
as="a"
href="https://cinny.in/#sponsor"
rel="noreferrer noopener"
target="_blank"
variant="Critical"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Heart} size="100" filled />}
>
Cinny
</a>{' '}
4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU
Affero General Public License v3; its source is at the Source Code link above.
</Text>
<Text size="B300">Support</Text>
</Button>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">

View file

@ -28,7 +28,7 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'ratatoskr-keys.txt');
FileSaver.saveAs(blob, 'cinny-keys.txt');
},
[mx]
)

View file

@ -27,7 +27,7 @@ function EmailNotification() {
device_display_name: email,
lang: 'en',
data: {
brand: 'Ratatoskr',
brand: 'Cinny',
},
append: true,
});

View file

@ -1,12 +0,0 @@
export interface DeviceHints {
deviceMemory?: number;
hardwareConcurrency?: number;
}
/**
* Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar
* section 7, item 4). Unknown hints (Safari reports neither) do not count as weak.
*/
export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean =>
(deviceMemory !== undefined && deviceMemory < 4) ||
(hardwareConcurrency !== undefined && hardwareConcurrency < 4);

View file

@ -1,108 +0,0 @@
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head
// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human
// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until
// Bifrost's steward cuts the list.
export type Estate = 'human' | 'mind' | 'persona';
export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert';
export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
export type Size = 'S' | 'M' | 'L';
export type Override = 'None' | 'Muted';
interface FrameBase {
mind: string;
estate: Estate;
override: Override;
seq: number;
/** Producer observation time, whole seconds (grammar: 1 s resolution). */
at: number;
}
/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */
export interface NeutralFrame extends FrameBase {
neutral: true;
size?: undefined;
kind?: undefined;
energy?: undefined;
}
export interface LiveFrame extends FrameBase {
neutral: false;
size: Size;
kind: Kind;
energy: Energy;
}
export type ActivityFrame = NeutralFrame | LiveFrame;
const ESTATES: readonly Estate[] = ['human', 'mind', 'persona'];
const KINDS: readonly Kind[] = [
'Idle',
'Reading',
'Thinking',
'Working',
'Speaking',
'Waiting',
'Alert',
];
const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak'];
const SIZES: readonly Size[] = ['S', 'M', 'L'];
/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */
export const STALE_AFTER_S = 10;
const oneOf = <T extends string>(set: readonly T[], v: unknown): T | undefined =>
set.find((s) => s === v);
/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */
export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking'];
/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */
const clampKind = (estate: Estate, kind: unknown): Kind | undefined =>
estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind);
/** A human's energy is three buckets: Peak reads as High. */
const clampEnergy = (estate: Estate, energy: Energy): Energy =>
estate === 'human' && energy === 'Peak' ? 'High' : energy;
/**
* Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a
* room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3).
* `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent
* them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4).
*/
export const readFrame = (raw: unknown): ActivityFrame | undefined => {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
if (typeof r.neutral !== 'boolean') return undefined;
const estate = oneOf(ESTATES, r.estate);
const override =
r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override);
if (!estate || !override) return undefined;
if (typeof r.mind !== 'string' || r.mind === '') return undefined;
if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined;
const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number };
if (r.neutral) return { ...base, neutral: true };
const kind = clampKind(estate, r.kind);
const energy = oneOf(ENERGIES, r.energy);
const size = oneOf(SIZES, r.size);
if (!kind || !energy || !size) return undefined;
return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) };
};
/** Latest frame per mind (S1: consumers drop seq <= last). */
export class FrameTable {
private frames = new Map<string, ActivityFrame>();
offer(raw: unknown): boolean {
const f = readFrame(raw);
if (!f) return false;
const last = this.frames.get(f.mind);
if (last && f.seq <= last.seq) return false;
this.frames.set(f.mind, f);
return true;
}
/** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */
fresh(nowS: number): ActivityFrame[] {
return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S);
}
}

View file

@ -1,8 +0,0 @@
import type { ActivityFrame, Size } from './frame';
/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */
export const PLACEHOLDER_SIZE_SCALE: Record<Size, number> = { S: 0.7, M: 1, L: 1.4 };
/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */
export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] =>
frames.filter((f) => !f.neutral);

View file

@ -1,47 +0,0 @@
import { FrameTable } from './frame';
import { drawnCreatures } from './scene';
import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices';
import { isWeakDevice, type DeviceHints } from './device';
/**
* Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the
* Heimdall session feed exists the only source is `silentSource`. A source never reads a room's
* member list (A1): being in a room says nothing about being active.
*/
export interface ActivitySource {
subscribe(onFrame: (raw: unknown) => void): () => void;
}
const noop = () => undefined;
export const silentSource: ActivitySource = { subscribe: () => noop };
/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */
export interface VoiceSink {
play(commands: readonly VoiceCommand[]): void;
}
export interface AudioSocketDeps {
source: ActivitySource;
sink: VoiceSink;
prefs: () => SoundscapePrefs;
device: () => DeviceHints;
/** Whole seconds. */
nowS: () => number;
}
export const createAudioSocket = (deps: AudioSocketDeps) => {
const table = new FrameTable();
const unsubscribe = deps.source.subscribe((raw) => table.offer(raw));
return {
/** Called by the scheduler tick; a hidden page simply stops calling it (S5). */
tick(): VoiceCommand[] {
const prefs = deps.prefs();
const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) };
const commands = planVoices(table.fresh(deps.nowS()), effective);
deps.sink.play(commands);
return commands;
},
/** What the orbit renderer draws: the same table, neutral removed. */
drawn: () => drawnCreatures(table.fresh(deps.nowS())),
close: unsubscribe,
};
};

View file

@ -1,233 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame';
import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene';
import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices';
import { isWeakDevice } from './device';
import { createAudioSocket, silentSource, type ActivitySource } from './socket';
const frame = (o: Partial<LiveFrame> = {}): LiveFrame => ({
mind: 'void',
estate: 'mind',
kind: 'Working',
energy: 'Mid',
size: 'M',
neutral: false,
override: 'None',
seq: 1,
at: 100,
...o,
});
const neutralFrame = (o: Partial<ActivityFrame> = {}): ActivityFrame =>
({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame);
/** A raw frame as a producer sends it, with the named keys omitted. */
const without = (f: object, ...keys: string[]) =>
Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k)));
const noop = () => undefined;
const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 };
describe('neutral overrides everything', () => {
it('draws nothing and sounds nothing for any kind, energy, size or estate', () => {
const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) =>
(['Speaking', 'Alert', 'Working'] as const).flatMap((kind) =>
(['High', 'Peak'] as const).flatMap((energy) =>
(['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size }))
)
)
);
expect(cases).toHaveLength(54);
cases.forEach((f) => {
expect(drawnCreatures([f])).toEqual([]);
expect(planVoices([f], on)).toEqual([]);
});
});
it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => {
const t = new FrameTable();
t.offer(frame({ at: 100 }));
expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]);
expect(drawnCreatures(t.fresh(100)).length).toBe(1);
});
});
describe('neutral is read first', () => {
it('yields no size, kind or energy, even when the producer sent them', () => {
(['human', 'mind', 'persona'] as const).forEach((estate) => {
const f = readFrame({
...frame({ estate, kind: 'Speaking', energy: 'High' }),
neutral: true,
});
expect(f).toBeDefined();
expect(f?.neutral).toBe(true);
expect(f?.size).toBeUndefined();
expect(Object.keys(f ?? {})).not.toContain('size');
expect(f?.kind).toBeUndefined();
expect(f?.energy).toBeUndefined();
expect(Object.keys(f ?? {})).not.toContain('kind');
expect(Object.keys(f ?? {})).not.toContain('energy');
});
});
it('a neutral human with kind omitted is not an Idle', () => {
const bare = without(frame({ estate: 'human' }), 'kind', 'energy');
const f = readFrame({ ...bare, neutral: true });
expect(f?.kind).toBeUndefined();
expect(drawnCreatures([f as ActivityFrame])).toEqual([]);
expect(planVoices([f as ActivityFrame], on)).toEqual([]);
});
});
describe('human kinds are the provisional subset', () => {
it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => {
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle');
});
it('keeps Idle, Working and Speaking', () => {
(['Idle', 'Working', 'Speaking'] as const).forEach((kind) =>
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind)
);
});
it('an omitted kind is Idle for a non-neutral human only', () => {
const noKind = without(frame({ estate: 'human' }), 'kind');
expect(readFrame(noKind)?.kind).toBe('Idle');
expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined();
});
it('minds and personas keep all seven kinds, and omitted is malformed for them', () => {
(['mind', 'persona'] as const).forEach((estate) => {
(['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach(
(kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind)
);
const noKind = without(frame({ estate }), 'kind');
expect(readFrame(noKind)).toBeUndefined();
});
});
});
describe('human energy is clamped to three buckets by the consumer', () => {
it('turns Peak into High for humans only', () => {
expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High');
expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak');
expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak');
});
it('keeps the clamp through the voice plan', () => {
const t = new FrameTable();
t.offer(frame({ estate: 'human', energy: 'Peak' }));
expect(planVoices(t.fresh(100), on)[0].energy).toBe('High');
});
});
describe('activity is a stub, never the room member list', () => {
it('the silent source and the socket yield nothing, however many members a room has', () => {
const members = ['@a:x', '@b:x', '@c:x'];
const sock = createAudioSocket({
source: silentSource,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
expect(members.length).toBeGreaterThan(0);
expect(sock.tick()).toEqual([]);
expect(sock.drawn()).toEqual([]);
});
it('only frames the source pushes produce voices', () => {
const pushes: ((raw: unknown) => void)[] = [];
const source: ActivitySource = {
subscribe: (cb) => {
pushes.push(cb);
return noop;
},
};
const sock = createAudioSocket({
source,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
pushes[0](frame({ mind: 'aqua' }));
expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']);
});
});
describe('no vote weights', () => {
it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => {
const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' });
expect(Object.keys(f ?? {}).sort()).toEqual(
['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort()
);
const t = new FrameTable();
t.offer({ ...frame(), weight: 9 });
expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5);
});
});
describe('size scales are placeholders', () => {
it('live in the renderer table, not in the frame, and change nothing about voices', () => {
expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 });
const s = planVoices([frame({ size: 'S' })], on);
const l = planVoices([frame({ size: 'L' })], on);
expect(s).toEqual(l);
});
});
describe('socket defaults and limits', () => {
it('ships off, muted, with the proposed cap', async () => {
vi.stubGlobal('localStorage', { getItem: () => null });
const { getSettings } = await import('../../state/settings');
const s = getSettings();
expect(s.soundscapeEnabled).toBe(false);
expect(s.soundscapeMuted).toBe(true);
expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP);
});
it('master mute, disabled flag and zero volume all silence', () => {
const f = [frame()];
expect(planVoices(f, { ...on, muted: true })).toEqual([]);
expect(planVoices(f, { ...on, enabled: false })).toEqual([]);
expect(planVoices(f, { ...on, volume: 0 })).toEqual([]);
});
it('caps voices, dropping the lowest energy first', () => {
const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) =>
frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] })
);
const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind);
expect(voices).toHaveLength(4);
expect(voices).not.toContain('a');
});
it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => {
const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })];
expect(planVoices(f, on)).toEqual([]);
expect(drawnCreatures(f)).toHaveLength(2);
});
it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => {
expect(isWeakDevice({ deviceMemory: 2 })).toBe(true);
expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true);
expect(isWeakDevice({})).toBe(false);
const played: number[] = [];
const sock = createAudioSocket({
source: {
subscribe: (cb) => {
cb(frame());
return noop;
},
},
sink: { play: (c) => played.push(c.length) },
prefs: () => on,
device: () => ({ deviceMemory: 1 }),
nowS: () => 100,
});
expect(sock.tick()).toEqual([]);
expect(played).toEqual([0]);
});
it('drops frames with seq <= last, and malformed frames', () => {
const t = new FrameTable();
expect(t.offer(frame({ seq: 5 }))).toBe(true);
expect(t.offer(frame({ seq: 5 }))).toBe(false);
expect(t.offer(frame({ seq: 4 }))).toBe(false);
expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false);
});
});
describe('neutral carries no size', () => {
it('reads without a size, and a live frame without one is malformed', () => {
const raw = frame();
expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined();
expect(readFrame(without(raw, 'size'))).toBeUndefined();
});
});

View file

@ -1,51 +0,0 @@
import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame';
/** Grammar V3 (proposed, unmeasured): voices audible at once. */
export const DEFAULT_VOICE_CAP = 4;
/** Local listener preferences. Never written back into a frame (O3). */
export interface SoundscapePrefs {
/** The feature flag. Default off. */
enabled: boolean;
/** Master mute. Default on. */
muted: boolean;
/** 0..1 */
volume: number;
voiceCap: number;
}
export interface VoiceCommand {
mind: string;
kind: Kind;
energy: Energy;
gain: number;
}
const ENERGY_RANK: Record<Energy, number> = { Low: 0, Mid: 1, High: 2, Peak: 3 };
/** Grammar section 3: Idle and Waiting default silent. */
const SILENT_KINDS: ReadonlySet<Kind> = new Set<Kind>(['Idle', 'Waiting']);
const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0);
/**
* Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2);
* over the cap the lowest energy drops first (V3, ties by mind id so the result is stable).
* Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight.
* Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen.
*/
export const planVoices = (
frames: readonly ActivityFrame[],
prefs: SoundscapePrefs
): VoiceCommand[] => {
if (!prefs.enabled || prefs.muted) return [];
const gain = clamp01(prefs.volume);
if (gain === 0) return [];
const cap = Math.max(0, Math.floor(prefs.voiceCap));
return frames
.filter(
(f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)
)
.sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind))
.slice(0, cap)
.map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain }));
};

View file

@ -1,5 +1,4 @@
import { createContext, useContext } from 'react';
import type { BackgroundSceneConfig } from '../features/scene/tier';
export type HashRouterConfig = {
enabled?: boolean;
@ -19,8 +18,6 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
backgroundScene?: BackgroundSceneConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -15,6 +15,7 @@ import {
addRoomIdToMDirect,
getDMRoomFor,
guessDmRoomUserId,
isRoomAlias,
isRoomId,
isServerName,
isUserId,
@ -25,7 +26,6 @@ import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
import { runJoinCommand } from '../features/estate/joinEstateRoom';
import { createRoomEncryptionState } from '../components/create-room';
export const SHRUG = '¯\\_(ツ)_/¯';
@ -227,7 +227,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
[Command.Join]: {
name: Command.Join,
description: 'Join room with address. Example: /join address1 address2',
exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)),
exe: async (payload) => {
const rawIds = splitWithSpace(payload);
const roomIdOrAliases = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
);
roomIdOrAliases.forEach(async (idOrAlias) => {
await mx.joinRoom(idOrAlias);
});
},
},
[Command.Leave]: {
name: Command.Leave,

View file

@ -5,26 +5,20 @@ import * as css from './styles.css';
export function AuthFooter() {
return (
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
<Text as="a" size="T300" href="https://gamecult.org" target="_blank" rel="noreferrer">
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
About
</Text>
<Text
as="a"
size="T300"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
href="https://github.com/ajbura/cinny/releases"
target="_blank"
rel="noreferrer"
>
Source
v4.12.8
</Text>
<Text
as="a"
size="T300"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer"
>
Based on Cinny
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
Powered by Matrix

View file

@ -20,7 +20,7 @@ import {
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@ -134,8 +134,8 @@ export function AuthLayout() {
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={RatatoskrSVG} alt="Ratatoskr logo" />
<Text size="H3">Ratatoskr</Text>
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H3">Cinny</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">

View file

@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: username,
},
password,
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
};
@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: mxIdUsername,
},
password,
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
address: email,
},
password,
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
};

View file

@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
}, [baseUrl, token, startLogin]);

View file

@ -109,7 +109,7 @@ function RegisterUIAFlow({
auth: authDict,
password,
username,
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
},
[onRegister, formData]
@ -250,7 +250,7 @@ export function PasswordRegisterForm({
auth: {
session: authData.session,
},
initial_device_display_name: 'Ratatoskr Web',
initial_device_display_name: 'Cinny Web',
});
};

View file

@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/ratatoskr.svg';
import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg';
import LogoSVG from '../../../../public/res/svg/cinny.svg';
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
export function WelcomePage() {
return (
@ -14,17 +14,17 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={RatatoskrSVG} alt="Ratatoskr logo" />}
title="Welcome to Ratatoskr"
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
title="Welcome to Cinny"
subTitle={
<span>
GameCult&apos;s Matrix client, based on{' '}
Yet another matrix client.{' '}
<a
href="https://github.com/cinnyapp/cinny"
href="https://github.com/cinnyapp/cinny/releases"
target="_blank"
rel="noreferrer noopener"
>
Cinny
v4.12.8
</a>
</span>
}
@ -33,7 +33,7 @@ export function WelcomePage() {
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
<Button
as="a"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
before={<Icon size="200" src={Icons.Code} />}
@ -42,6 +42,18 @@ export function WelcomePage() {
Source Code
</Text>
</Button>
<Button
as="a"
href="https://cinny.in/#sponsor"
target="_blank"
rel="noreferrer noopener"
fill="Soft"
before={<Icon size="200" src={Icons.Heart} />}
>
<Text as="span" size="B400" truncate>
Support
</Text>
</Button>
</Box>
</Box>
</PageHero>

View file

@ -47,12 +47,6 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
/** Soundscape flag: default off, and muted by default even when on. */
soundscapeEnabled: boolean;
soundscapeMuted: boolean;
soundscapeVolume: number;
soundscapeVoiceCap: number;
}
const defaultSettings: Settings = {
@ -87,11 +81,6 @@ const defaultSettings: Settings = {
dateFormatString: 'D MMM YYYY',
developerTools: false,
soundscapeEnabled: false,
soundscapeMuted: true,
soundscapeVolume: 0.5,
soundscapeVoiceCap: 4,
};
export const getSettings = () => {