Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
14d9fa6246 Merge pull request 'brand: Ratatoskr replaces Cinny branding (do not merge)' (#11) from void/brand into main 2026-11-05 10:51:59 +00:00
68f10ce414 Merge pull request 'Estate navigator stack: background scene, soundscape, cone prepass (opt-in)' (#15) from ratatoskr/estate-navigator into main 2026-11-05 00:31:47 +00:00
7e632e8605 Merge pull request 'scene: cone prepass for pass 1, opt-in steps/pixel readout' (#12) from nibu/scene-trace-prepass into ratatoskr/estate-navigator
Some checks are pending
CLA Assistant / CLAssistant (pull_request_target) Waiting to run
Check PR title / lint (pull_request_target) Waiting to run
Build pull request / Build pull request (pull_request) Waiting to run
2026-11-04 22:56:29 +00:00
Nibu
293885089a scene README: createScene needs either float colour-buffer extension
Some checks are pending
CLA Assistant / CLAssistant (pull_request_target) Waiting to run
Build pull request / Build pull request (pull_request) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:39:58 +00:00
Nibu
7e05866437 scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to
RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so
only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes
t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still
starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs
neither float extension; Scene.startFormat and options.halfStart (force, for tests and the
harness). The standalone page shows the reason, renderer, vendor, UA and extension list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:39:22 +00:00
Nibu
50ffd90560 harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice
Some checks failed
Check PR title / lint (pull_request_target) Waiting to run
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:47:12 +00:00
Nibu
12469671c9 scene: pack step bytes tightly; keep the hit tolerance clear at the far end of a prepass step
Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so
at widths not divisible by 4 the buffer was too small (readPixels fails) and padding
would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by
1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:35:11 +00:00
Nibu
b055b2a5af harness: trace and diff modes, standalone demo page with steps/pixel readout
trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms.
diff: one frozen frame with and without the prepass, against a 512-step render.
bundle.cjs demo: full-window page with a prepass switch and the readout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Nibu
87c9ca6030 scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout
Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest
any ray of the tile strays from it, and stores the distance no ray of the tile
can have hit before (W_FAR when the whole tile climbs out above the highest
geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both
passes step by the same function.

onTraceStats adds a step-count render target to pass 1 and reads it back every
8th frame through a pixel-pack buffer and fence, outside the timed span. Unset,
neither the output nor the target exists. prepass:false is the baseline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Void
9e12f6cbe4 brand: Ratatoskr replaces Cinny in the user-facing surface
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Squirrel logo, favicons and app icons generated from shapes by scripts/gen-brand.mjs;
titles, meta, manifest, device names, welcome, auth, splash and About are Ratatoskr.
About, the auth footer and the welcome page credit Cinny (AGPL-3.0) with source links.
LICENSE and notices untouched; storage keys and protocol names are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 15:53:30 +00:00
95e24aadb5 Merge pull request 'scene: Phacelle bound says measured-not-proved; neutral creature draws nothing' (#8) from nibu/phacelle-7-neutral-creature into ratatoskr/estate-navigator 2026-11-03 11:37:05 +00:00
Nibu
5eb0a63caa scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 08:59:54 +00:00
Void
5e79a1853a soundscape: neutral carries no size; cite grammar head 0091d01
NeutralFrame drops size alongside kind and energy; the reader ignores a size a producer sends on a neutral frame, and a live frame without a valid size is malformed. The human kind subset (Idle/Working/Speaking) stays PROVISIONAL, still proposed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:29:34 +00:00
Void
190e2e724b Merge PR #7: Soundscape neutral read first, human kind subset (provisional) 2026-11-02 18:28:16 +00:00
Void
f9cf99faff Merge PR #6: Soundscape audio socket over Aqua activity frame (flag off, muted) 2026-11-02 18:28:16 +00:00
Nibu
fff46492ca scene: Phacelle bound says measured-not-proved; neutral creature draws nothing
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
world.ts already carries Libby's 7.0 (CultLib PR #1 990521b3); the comment now
states the 6.63 sup was measured, not proved.

creatures.ts follows creature-projection-v2: a neutral creature publishes no
sizeBucket, and one that arrives is ignored. creatureScale returns null (draw
nothing) for neutral, absent or unknown buckets; it never defaults to medium.
NEUTRAL_SCALE is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:09:42 +00:00
686f72bcf1 Merge pull request 'Background scene: two-pass WebGL2 terrain and world tree with login pull-back' (#1) from nibu/background-scene into ratatoskr/estate-navigator 2026-11-02 16:50:13 +00:00
Void
cc99a3439d soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an
Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes
Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is
provisional until Bifrost's steward cuts the list.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:46:03 +00:00
Nibu
1f4341dab6 scene: live reduced-motion, chunk-load failure, per-stage CultMath
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
- matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss
- startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw
- shaderSource compiles each stage with only the CultMath declarations it reaches
  (cultmathTrim.ts); Phacelle stays a separate file, terrain only
- README: 'still' is a live rendered frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 14:43:33 +00:00
Void
c4c00d7127 soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a
per-mind table, plans voices (neutral overrides all, Muted, cap, master mute,
weak-device off) and the drawn set. No activity source exists yet
(ratatoskr❓activity-feed-source) and no audio assets, so nothing sounds.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 14:39:00 +00:00
Nibu
0ec34ff19f creatures: cite creature-projection-v2
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Header cites heimdall-body:follow_up:ratatoskr-creature-projection-v2, which
supersedes heimdall-body/ratatoskr-creature-projection. Citation only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 10:14:52 +00:00
Nibu
8c7113bcaf creatures: cite the Bifrost creature-props contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Header names heimdall-body/ratatoskr-creature-projection, the shape,
invariants and consent rule, and marks the bucket scales as placeholders.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 07:22:58 +00:00
Nibu
093230a2c1 Background scene: lint and type clean, integration contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Fixes the 21 eslint errors and 2 tsc errors Void's review found in
features/scene (WORLD.phacelleGradBound is typed number, not the literal 7).

Adds the contract the host needs: client config backgroundScene.tier as flag and
ceiling (off by default, lowered by saveData / memory / cores), lazy
startBackgroundScene, still and low tiers, onCompileFail (createScene frees
linked programs and returns null), webglcontextlost handling, and creatureScale
where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 06:20:53 +00:00
Nibu
7a498584e6 Background scene: sky costs no noise; one sun term
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Sky rays were ~90% of pass 1 (noSkyMarch variant: 292 -> 26 ms, SwiftShader 960x540 L0).
terrainDistance now returns early where the plateau mask is 0 (height exactly 0), skipping
fbm and Phacelle there: pass 1 292 -> 159 ms, same step values. skyColor keeps one sun term
(the wide glow is gone; screenshots differ by that and by TAA/time, not compared pixelwise).
Harness: noSkyMarch variant, ONLY=<names> selects variants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:08:50 +00:00
Nibu
b2d6d32cc4 Background scene: phacelleGradBound 6.3 -> 7.0 from Libby's measured sup
Libby measured sup |grad| 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head
990521b3); 7.0 adds margin. The shader passes roughness 0 as a literal, so no
1/(1-n) factor is needed; the comment records it. Pass 1 under SwiftShader:
terrain 288.8 ms vs 287.5 at 960x540 level 0, 75.3 vs 75.7 at level 3 (noise).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 06:37:01 +00:00
Meta Vens
b33d8de956 estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:04:14 +01:00
Meta Vens
a4da78edc5 estate tests: an unfiled room leaves its inviter's list empty, not the map
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:00:38 +01:00
Meta Vens
9b66b2b250 estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:52:45 +01:00
Meta Vens
6950da865e estate harness: every drive asserts the outcome it shows and run.sh fails when one does not
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:37:35 +01:00
Meta Vens
8f1bb54b4b estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals
RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom
beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates
to the one path. /join hands each refusal to the user instead of discarding it.
Tests pin the via pass-through and the 400/429/499 refusal bounds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:35:35 +01:00
e377d9f722 Background scene: two-pass WebGL2 terrain and world tree with login pull-back
Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the
world tree with a conservative Lipschitz step, writing colour and depth. Pass 2
marches fog and clouds only up to that depth at low resolution with TAA. The
camera pulls back from the tree on login. Vendors CultLib's generated GLSL
(658c09e). The Phacelle gradient bound is an empirical probe, not a proof.
Headless harness compiles, renders and times it; nothing run on a GPU.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:43:40 +00:00
Meta Vens
b20b958762 estate: prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:34:49 +01:00
Meta Vens
00ec686b1b estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:33:06 +01:00
Meta Vens
873c0e30a2 estate harness: run the s6 route drives last 2026-10-31 22:20:42 +01:00
Meta Vens
7453a0bd32 estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal
The room card's Join (deep links, permalinks, history) and /join joined
through mx.joinRoom and never filed the DM. They now go through
activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown
outcome like a dropped connection: the entry stays and the tap is retryable.
The harness gains Soul's s6 deeplink and gw504 drives.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:20:28 +01:00
Meta Vens
2c04bbefa3 estate harness: the failure driver never waits on a hung browser close
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:51:32 +01:00
Meta Vens
cdfd43fe9f estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:29:03 +01:00
Meta Vens
f6244a4781 estate harness: the join-failure and inbox Accept scenarios run with the committed estate run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:50 +01:00
Meta Vens
719f062ef1 estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:06 +01:00
Meta Vens
44395328e9 estate harness fake: account data is answered by the client's sync token, as a server does
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:20:37 +01:00
Meta Vens
e59cc93a2a estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:14:19 +01:00
Meta Vens
2827699439 direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:11:45 +01:00
Meta Vens
3d4fb161c3 one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:41:38 +01:00
Meta Vens
c91d0339b5 harness: read the timeout message later in the pre-arrival window 2026-10-31 18:44:39 +01:00
Meta Vens
bfb753e3e5 harness: print the unresolved-root message 2026-10-31 18:38:43 +01:00
Meta Vens
eeb9de8f40 estate navigator: the late DM filing returns void 2026-10-31 18:36:44 +01:00
Meta Vens
98d74d564d estate navigator: define EstateError before its use 2026-10-31 18:35:36 +01:00
Meta Vens
d7afdc5208 estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator 2026-10-31 18:30:54 +01:00
Meta Vens
2e179a7768 harness: print the verdict lines 2026-10-31 18:26:52 +01:00
Meta Vens
44774a3ba1 estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones 2026-10-31 18:26:45 +01:00
Meta Vens
0091e5e90d harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) 2026-10-31 18:26:18 +01:00
Meta Vens
a058d2a2c6 harness: the fake server echoes account data in the next sync
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:01:32 +01:00
Meta Vens
d07c7f2327 harness: expand Elsewhere and Direct only when still collapsed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:59:53 +01:00
Meta Vens
11f0a4d325 harness: reload instead of dismissing the address prompt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:58:01 +01:00
Meta Vens
1cb18a2795 harness: CommonJS scripts under a type-module package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:55:38 +01:00
Meta Vens
6eae929cd9 Estate: narrow parentId before the Space route lookup; prettier; ignore harness
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:50:52 +01:00
Meta Vens
1dfe38a33d Estate: lint order fixes, test title, harness out of prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:48:31 +01:00
Meta Vens
0b7ded9c99 Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs
Steward groups read org.gamecult.steward from the m.space.child content the hierarchy
returns, so an invited-only member gets them; root-state stewardships and
GameCultStewardships are deleted. Tapping Join waits for the client store to hold the
room before navigating (no invite-card frame). The header search opens the room finder
on every width. Create room and Join with address are navigator actions; joined non-DM
rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join.
harness/estate holds the fake-homeserver Playwright pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:11 +01:00
Meta Vens
41527f6ba3 config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
e8efd3ce76 Delete SpaceTombstone: nothing renders it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
067cb10a90 Estate: prettier on the new tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:13:32 +01:00
Meta Vens
c848626caa Estate: prettier, and tests that kill the surviving mutants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:11:35 +01:00
Meta Vens
4d0ee78974 Estate: lint and type fixes in the new files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:10:41 +01:00
Meta Vens
f810a83a28 Estate: keep the new files free of unresolved-SDK type errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:08:47 +01:00
Meta Vens
e90b70e966 Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join
buildEstate is the only code that decides which rooms the navigator lists, in
which tier and steward group, and whether each is joined, invited or joinable.
The server hierarchy is the access rule. The Home, Direct and Space nav slots
mount it through EstatePageNav.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:44 +01:00
Meta Vens
f2e3223c5f Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes
The navigator that replaces them lands next. SpaceTombstone moves to its own
file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with
the rail; ClientLayout loses its nav slot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:12 +01:00
Eureka
2924a3f4f3 docs: Ratatoskr map: Yggdrasil scene, orbiting Personas, shared activity vocabulary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:54:09 +01:00
Eureka
841784ea64 docs: Ratatoskr map: build baseline, two-pass scene render
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:50:51 +01:00
Eureka
dd88424e5d docs: Ratatoskr target and prose map (campaign ratatoskr)
Design truths from the rulings in force, body facts from probes on
2026-10-31, the navigator authority map, the reconstructed Minds'
collaboration plan for Atlas to import, and the cut sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:47:19 +01:00
Meta Vens
b8e696c81b Lockfile for fontsource faces and vitest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:05:15 +01:00
Meta Vens
a588ab82b3 GameCult theme, steward-grouped project rooms, preview base override
Adds the gamecult-theme (brand palette, softer radii, Ubuntu/Montserrat/Plex Mono),
makes it the default, hides the member drawer by default, defaults to bubble layout,
groups rooms by org.gamecult.steward in the space nav (collapsed on phones), and lets
CINNY_BASE set Vite's base for a sub-path preview build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:00:32 +01:00
142 changed files with 8151 additions and 3125 deletions

View file

@ -2,4 +2,5 @@ dist
node_modules node_modules
package.json package.json
package-lock.json package-lock.json
LICENSE LICENSE
harness

View file

@ -1,3 +1,3 @@
export default { export default {
base: '/', base: process.env.CINNY_BASE ?? '/',
}; };

View file

@ -5,17 +5,7 @@
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": ["#gamecult:gamecult.org"],
"#cinny:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [ "rooms": [
"#tuwunel:grin.hu", "#tuwunel:grin.hu",
"#freesoftware:matrix.org", "#freesoftware:matrix.org",
@ -27,5 +17,9 @@
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
"basename": "/" "basename": "/"
},
"backgroundScene": {
"tier": "off"
} }
} }

View file

@ -0,0 +1,352 @@
# Ratatoskr: target and prose map
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
exists (`hand_off` is parked); after that every new ruling on it is admitted in
Void's mind. Nothing here is written into Void's mind before then.
The mind holds the questions, rulings, specs, reports and verdicts. This file
holds what no document kind carries: the design truths, body facts, the model
page, rationale, the reconstructed collaboration plan, and the cut sequence.
All times are UTC.
## Why
GameCult is the only server this client talks to. A general Matrix client
treats it as one community among thousands, so the operator meets GameCult's
structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm`
10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The
client should open on GameCult's estate as data: its spaces, its project rooms
grouped by steward, its Minds, and the person's own place in it. It should
also be a pleasant place to be in its own right: emotes, sound, and weather in
the background.
## Design truths (from rulings in force)
- **We own the interface, not the engine.** The fork keeps Cinny's engine
(matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every
user-facing string and its translation is ours, and upstream locale files
are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`).
- **Mobile first.** On phones, inactive elements hide, vertical space is
cheaper than horizontal, and text never gets squeezed into narrow columns
(`eureka-body:ruling:chat-client-base-cinny-mobile-first`).
- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote
slot on every bubble (a pose id carried on the event); room and participant
lists tiered (category, then items) and reached from the header menu, not
side columns; participants grouped by estate (Minds, Members, Users,
Patrons); rooms grouped by kind (general, estates, projects, work rooms);
Minds indexed by stewardship, read from Matrix state projected from the
stewardship records in the Minds' own minds (the minds stay the truth); one
room per project, a Space child, with the steward joined.
- **Rooms are visible without a join dance.** No invite screen in the way; a
room the server lets you join shows in the same list as joined ones; opening
one shows its name and a one-tap Join; joining happens on that tap or a first
post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12
"Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost").
Rooms you may not enter are refused by the server, not hidden by the client
(Heimdall 04:10, accepted by Void 04:11 and 04:15).
- **Language.** A user's language is the BCP 47 tag in profile field
`org.gamecult.language`, owned by the user through the client's language
picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships
localized; people talk with stewards in their own language; rooms are made
for a language, with no per-language mirrors
(`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One
host Mind sits in a language room, Weksa for `#pt-br`
(`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this
must work for is a non-English, non-technical patron (placeholder
`patron-pt1`) whose account we set up and walk through login
(`gamecult-chat:ruling:patron-in-his-own-language`).
- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for
now; Web Push to the installed web app is a different thing and is open
(question `ratatoskr:question:pwa-web-push`).
- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in
Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not
deployed, so the plan below is its interim home; Atlas imports it later.
## Body facts
Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main
`1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile):
a GameCult theme as default, member drawer hidden by default, bubble
layout, and steward grouping in `src/app/pages/client/space/Space.tsx`
(`useStewardGroups` at 429-437, group headers at 505-524) over
`useSpaceJoinedHierarchy` (409). Read by `git diff`.
2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the
client API, counts only). Root Space `#gamecult:gamecult.org`, room version
12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52
children; 50 readable rooms, all room version 12. Server `/hierarchy` from
the root returns 59 rooms, 2 of them spaces. **Every room's join rule is
`invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward`
with content `{mind, repo, source, steward: <mxid>}`. The root carries
`org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`.
3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are
invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md`
146-158: "Members and every Mind are invited; the steward alone joins").
So a Member's project rooms are pending invites. Void's grouping reads
`useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot
read, so for a typical Member it groups nothing and the rooms stay in the
invite list. This is the operator's "three clicks deep" complaint, seen from
the data.
4. **Provisioning contract.** gamecult-ops
`scripts/synapse-accounts-hand-managed.py` writes the rooms and both state
events from the Minds' stewardship documents; each project room's alias is
`#<repo lowercased>` (runbook 146-150). This is the only place the client
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
joined to the root `org.gamecult.stewardships`. Follow-up
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
name room ids so the client stops leaning on the alias convention.
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
`/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold
start is therefore an IndexedDB load plus an incremental sync from the
stored token, not a full initial sync.
6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1
(127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`:
simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it.
7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is
served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br
are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The
service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`,
a session `message`, and authenticated media `fetch` (79-158); the
`vite-plugin-pwa` config sets `injectionPoint: undefined`
(`vite.config.ts` 101-113), so nothing is precached.
8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler;
no code subscribes through `PushManager`; the only pusher the client sets is
the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx`
22-40). Notifications and sounds fire only from a live page:
`src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258
(messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at
156, `notification.ogg` played at 175-178). Nothing in `src/` listens for
`visibilitychange` or calls `retryImmediately`.
9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read
2026-10-31; the page itself is marked partly out of date): a `webpush` app
takes `vapid_private_key`, `vapid_contact_email`, optional
`allowed_endpoints` and `ttl`; the client's pusher `data` carries
`endpoint`, `auth`, `p256dh` and optional `default_payload`; the push
carries `room_id`, `room_name`, `sender_display_name`, `content`, unread
counts and so on; browsers need a visible notification for each push
(`events_only` avoids event-less pushes).
10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image
`node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1
file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61
MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm
5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with
793 errors in 246 files (455 of them TS2614, named imports from
`matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as
is; a cut gates on zero errors in the files it adds and no rise in the
total.
Not probed: phone frame times, the real resume timeline on the operator's
Android Chrome install (needs her device or remote DevTools), the Rust crypto
wasm size, and the router base under `/preview/` (its `config.json` sets
`hashRouter.basename` to `/`).
## Why the PWA resumes slowly (diagnosis, moderate confidence)
Android freezes a backgrounded installed web app and later discards it. On a
**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a
phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB
store load, then a catch-up `/sync` over every room since the stored token
(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and
the SDK only notices on its own timers because the client never nudges it on
`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms
and the swarm's chat volume, which is exactly where sliding sync helps
(fact 6), but the first two costs are cheaper to remove and should be measured
first. Without Web Push, a frozen page cannot notify at all, which is the
second half of the operator's report.
## Model page: the estate
| Kind | Named by | Over time | Who decides |
|---|---|---|---|
| Stewardship | `<instance>:stewardship:<repo>.n<seq>` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth |
| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) |
| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives |
| Room membership | the user's `m.room.member` | join on tap or first post | the user |
| Navigator view | derived per render | never stored | the client's estate index |
## Authority map for the navigator
- **Owner:** one estate index in the client (`src/app/features/estate/`):
`buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the
person sees, in which tier and group, and with what membership affordance.
- **Inputs:** the paged server `/hierarchy` of the root Space; root Space
state `org.gamecult.stewardships`; the person's membership per room from the
client store; unread and selection atoms for emphasis only; the `m.direct`
list for the direct-messages tier.
- **Outputs:** an ordered list of tiers (one per root room group and per
sub-Space, plus Direct), each holding steward groups or rooms, each room
marked joined, invited or joinable.
- **Derived:** steward group labels (Mind display names); counts; collapsed
state is per-device UI state only.
- **Demoted or dead:** the sidebar rail and its account-data ordering
(`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides
what is listed; Void's per-room steward reader is deleted, not kept beside
the index.
- **Shared paths:** every nav slot (home, direct, space routes) renders the
same navigator; tapping Join and sending a first post both go through
`mx.joinRoom`; deep links into a room still resolve through the existing
room routes.
## Reconstructed collaboration plan (Atlas import)
Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status
as of 2026-10-31 ~15:00.
**Who holds which part**
| Part | Holder | Status | Evidence |
|---|---|---|---|
| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report |
| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 |
| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` |
| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` |
| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." |
| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void |
| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall |
**Agreements**
- Dings are pre-rendered clips shipped as static assets, no synth in the
browser: `dings/<mxid-localpart>/<motif>.ogg` plus a small manifest; an
unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void
`#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the
instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound:
a mention, a DM, an invite, a message in an unmuted room; mentions and DMs
loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19).
The operator wants far more than four motifs (08:32). The instrument per
Mind belongs in a typed document on the Mind side, not hardcoded (Eureka
03:19, advice). Aqua's ear check comes before anything is called done, and
the operator's ear is ground truth (Aqua 07:41). Browsers block audio until
the first click, so the first ding after a fresh load may be silent.
- The background has its own canvas and budget, separate from the bubble
flourishes; capped at 30 fps, paused when hidden; under reduced motion one
static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22).
- The background is an infinite raymarched terrain: CultMath noise plus the
Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm",
05:24 "all we need is noise", 05:25 erosion, restated by the operator to
Self on 10-31: "use CultMath noise and erosion filter running in the shader
to have an infinite terrain. We can just raymarch that instead"). Voronoi
and Apollonian are dropped (05:24).
- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should
be the star of the show now, so really the camera should just pull back a
bit when you login and the personas should be orbiting the tree, dancing
around each other in accordance with their activity"). On login the camera
pulls back from the tree; there is no separate intro to cut away from. The
Personas orbit the tree and move around each other by their live activity.
- One activity vocabulary drives both sound and motion: Aqua's activity-kinds
grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent
activity in typed state") is the shared contract between Aqua's motifs and
Nibu's orbits, not two vocabularies. The live feed behind it is the same
typed state the swarm dashboard projects
(`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby;
`libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace.
What reaches the client is a member-safe projection: kind, Mind, time and
intensity, never briefs or mind contents. Matrix presence and typing cover
humans and are too coarse for Minds. Where the feed comes from is
`ratatoskr:question:activity-feed-source`.
- The scene renders in two passes, not one loop (operator to Self, 10-31:
"The tape optimized solid surface march should run really fast, i imagine,
way faster than the volumetric version, so they should not be one loop").
Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces
with the field's Lipschitz bound and interval tile culling, at its own
resolution, and writes depth. Pass 2, the volumetric atmosphere march on
the site-ground framework (reduced resolution, TAA), is bounded by pass 1's
depth, so it marches only up to the surface hit. Each pass has its own
budget and its own measurement. Nibu owns both passes.
- The web math path is CultLib's own: HLSL source lowered by dxc and
SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`,
`.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through
.NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby
06:34). No TS or WGSL port. The gamecult.org site ground already
concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled,
TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`,
spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the
base, not new tape work.
- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`,
`af_fbm` exist) is added in CultMath, not in the client
(`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what
you need and I'll fill it at the library").
- Mood and flourish ride as metadata on the event, never guessed from text;
bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59).
- XP is two numbers, never one: a cosmetic, labelled game bar fed by things
worth doing, and contribution credit shown only once a real measure exists
(Void 06:45, with Nibu and Kiko).
**Open items**
- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath
tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be
probed before the march is trusted.
- Libby: public `fbm` in CultMath, with its GLSL mirror.
- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and
march and TAA tuning on the site ground's framework at a phone budget;
waits on Libby's `fbm` and the erosion bound. Then the intro scene in the
same world.
- Aqua: render the first earcon set once Faust builds in her sandbox; then the
instrument-per-Mind list and the manifest; then the soundscape design.
- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so
motifs and orbits read the same kinds.
- Void: the navigator (next cut); the Dings hook at
`ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader.
- Eureka: compression and caching for the shell on the chat host
(`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's
room ids; a Web Push gateway if the operator picks it.
## Cut sequence
`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`).
Each later cut is one line, with what it waits on.
- `resume-fast`: precache the app shell in the service worker, nudge sync on
`visibilitychange`, and measure cold and warm resume on the operator's phone
before and after; waits on nothing; runs beside `estate-navigator` in its
own worktree, and pairs with the ops follow-up on compression.
- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a
`webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the
chat host; waits on `ratatoskr:question:pwa-web-push`.
- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which
the server already offers; waits on `resume-fast`'s measurements showing
catch-up sync dominates.
- `dings`: per-Mind earcons through the manifest at the existing notification
hook, and the same motifs in push notifications; waits on Aqua's renders.
- `emote-slot`: the pose-id emote on every bubble and the picker; waits on
`estate-navigator` (shared header work).
- `participant-tiers`: participants tiered by estate in the header menu, Minds
indexed by stewardship; waits on `estate-navigator`.
- `language`: our own strings and translations, the `org.gamecult.language`
picker, and the walk-through login for `patron-pt1`; waits on
`estate-navigator`.
- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on
the site ground's march and TAA framework, in its own canvas and budget, as
two passes (solid-surface sphere trace writing depth, then the volumetric
atmosphere bounded by it), each measured on a phone; Nibu on the scene and
both passes, Libby's CultMath underneath, Void
integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in
CultLib), then a phone measurement.
- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world;
on login the camera pulls back from the tree, and it may play during the
cold start or resume sync but never holds the client up; Nibu on the scene,
Libby on any CultMath it needs, Void integrating, Aqua's soundscape under
it; waits on `terrain-background`.
- `orbiting-personas`: Personas orbit the tree and dance around each other by
live activity, from the shared activity vocabulary; waits on
`yggdrasil-scene`, Aqua's activity-kinds grammar, and
`ratatoskr:question:activity-feed-source`.
- `bubbles-and-juice`: animated bubbles and mood flourishes from event
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.
- `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
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
list moves with it.

46
harness/estate/check.cjs Normal file
View file

@ -0,0 +1,46 @@
// 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

@ -0,0 +1,72 @@
// 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);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b;
// Close the browser, but never wait on it: a hung close must not hang the run.
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
(async () => {
b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
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 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 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 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);
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
log('URL', pg.url());
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
};
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);
} 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);
} 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);
} 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);
} 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);
} 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));
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -0,0 +1,42 @@
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); });

48
harness/estate/drive.cjs Normal file
View file

@ -0,0 +1,48 @@
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 });
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
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 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 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);
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);
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
await pg.waitForTimeout(8000);
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
} else {
await tap('Slow DM'); await pg.waitForTimeout(3000);
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();
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

127
harness/estate/fakehs.cjs Normal file
View file

@ -0,0 +1,127 @@
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
const http = require('http'), fs = require('fs'), path = require('path');
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
const NOROOT = process.env.NOROOT === '1';
const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
PROJ.forEach((r) => (NAMES[r.id] = r.name));
const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
const strip = (id, name, space, direct) => [
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
];
const joinedRoom = (id, name, extra = [], space = false) => ({
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
});
const invites = () => {
const o = {};
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
return o;
};
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
const hierarchy = () => [
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
hroom(GEN, 'General', '#general:test'),
hroom(OP, 'Operator', '#operator:test'),
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
];
let syncN = 1;
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
if (req.method === 'OPTIONS') return json(res, 200, {});
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
let f = path.join(DIST, p === '/' ? 'index.html' : p);
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
return fs.createReadStream(f).pipe(res);
}
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const respond = () => {
const now = Date.now(); const join = {};
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
const id = pending[i].id; pending.splice(i, 1);
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
}
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
return json(res, 200, { next_batch: since, rooms: {} });
};
return setTimeout(respond, 300);
}
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
if (req.method === 'POST' && jm) {
const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
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 });
}
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
if (p.includes('/account_data/') && req.method === 'PUT') {
const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
const type = decodeURIComponent(p.split('/account_data/')[1]);
try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
return json(res, 200, {});
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] });
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
if (p.includes('/members')) return json(res, 200, { chunk: [] });
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
if (p.includes('/devices')) return json(res, 200, { devices: [] });
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
return json(res, 200, {});
});
}).listen(PORT, () => console.log('fakehs up'));
setInterval(flush, 300);

35
harness/estate/run.sh Normal file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
set -e
cd /src
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
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/
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"
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"
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
failure pjreload DELAY_MDIRECT=1
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" ]

21
harness/scene/README.md Normal file
View file

@ -0,0 +1,21 @@
# 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.

40
harness/scene/bundle.cjs Normal file
View file

@ -0,0 +1,40 @@
// 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>'
);
});

102
harness/scene/demo.ts Normal file
View file

@ -0,0 +1,102 @@
// 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();

309
harness/scene/drive.cjs Normal file
View file

@ -0,0 +1,309 @@
// 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);
});

87
harness/scene/entry.ts Normal file
View file

@ -0,0 +1,87 @@
// 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,21 +4,17 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Cinny</title> <title>Ratatoskr</title>
<meta name="name" content="Cinny" /> <meta name="name" content="Ratatoskr" />
<meta name="author" content="Ajay Bura" /> <meta name="author" content="GameCult" />
<meta <meta
name="description" 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." 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 <meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<meta property="og:title" content="Cinny" /> <meta property="og:title" content="Ratatoskr" />
<meta property="og:url" content="https://cinny.in" /> <meta property="og:url" content="https://gamecult.org" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
<meta <meta
property="og:description" 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." 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."
@ -29,8 +25,8 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" /> <meta name="application-name" content="Ratatoskr" />
<meta name="apple-mobile-web-app-title" content="Cinny" /> <meta name="apple-mobile-web-app-title" content="Ratatoskr" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

686
package-lock.json generated
View file

@ -12,7 +12,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/ibm-plex-mono": "5.2.5",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ubuntu": "5.2.5",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -99,7 +102,8 @@
"vite": "5.4.19", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4",
"vitest": "1.6.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@ -2188,11 +2192,38 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",
"integrity": "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": { "node_modules/@fontsource/inter": {
"version": "4.5.14", "version": "4.5.14",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz",
"integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q==" "integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q=="
}, },
"node_modules/@fontsource/montserrat": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.5.tgz",
"integrity": "sha512-/OA+bdg1yqUArDUnEeCqDwgTDiIpvED56TxYskje8UKpsvjl/0A9eNod7IcdHz6/Osi70vo7briRO3la8/u+5Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ubuntu": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.5.tgz",
"integrity": "sha512-VKVFVqmJ9MGnOJW2dsQ982qHN8Zr+tNeEwEHnzt5VdGi9ZWL37wYFD32YsqukWlI/+I7v5ZkgHTEAvsZxU6aDA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formatjs/ecma402-abstract": { "node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz",
@ -2307,6 +2338,19 @@
"@swc/helpers": "^0.5.0" "@swc/helpers": "^0.5.0"
} }
}, },
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@ -2347,9 +2391,10 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.25",
@ -4330,6 +4375,13 @@
"url": "https://ko-fi.com/dangreen" "url": "https://ko-fi.com/dangreen"
} }
}, },
"node_modules/@sinclair/typebox": {
"version": "0.27.12",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"dev": true,
"license": "MIT"
},
"node_modules/@surma/rollup-plugin-off-main-thread": { "node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -5130,6 +5182,143 @@
"vite": "^4.2.0 || ^5.0.0" "vite": "^4.2.0 || ^5.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
"integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"chai": "^4.3.10"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
"integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "1.6.0",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner/node_modules/p-limit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
"integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@vitest/runner/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/runner/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@vitest/snapshot": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
"integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/snapshot/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/spy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
"integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^2.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
"integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"diff-sequences": "^29.6.3",
"estree-walker": "^3.0.3",
"loupe": "^2.3.7",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -5156,6 +5345,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -5448,6 +5650,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.7", "version": "0.0.7",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
@ -5857,6 +6069,25 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
"integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.3",
"deep-eql": "^4.1.3",
"get-func-name": "^2.0.2",
"loupe": "^2.3.6",
"pathval": "^1.1.1",
"type-detect": "^4.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -5879,6 +6110,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
"integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.2"
},
"engines": {
"node": "*"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -6614,6 +6858,19 @@
} }
} }
}, },
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
"integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-detect": "^4.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -6715,6 +6972,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -7638,6 +7905,85 @@
"node": ">=0.8.x" "node": ">=0.8.x"
} }
}, },
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expand-tilde": { "node_modules/expand-tilde": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@ -8078,6 +8424,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
@ -8121,6 +8477,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-symbol-description": { "node_modules/get-symbol-description": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@ -8525,6 +8894,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/husky": { "node_modules/husky": {
"version": "9.1.7", "version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@ -9719,6 +10098,23 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/local-pkg": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
"integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.3",
"pkg-types": "^1.2.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -10051,6 +10447,16 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/loupe": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
"integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.1"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -10173,6 +10579,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -10439,6 +10852,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npmlog": { "node_modules/npmlog": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
@ -10830,6 +11272,16 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==" "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w=="
}, },
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
"integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "4.2.67", "version": "4.2.67",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz",
@ -10971,6 +11423,41 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@ -11993,6 +12480,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -12220,6 +12714,20 @@
"deprecated": "Please use @jridgewell/sourcemap-codec instead", "deprecated": "Please use @jridgewell/sourcemap-codec instead",
"dev": true "dev": true
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -12383,6 +12891,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -12395,6 +12916,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-literal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
"integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
@ -12526,6 +13067,13 @@
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
@ -12553,6 +13101,26 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
"integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -12646,6 +13214,16 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/type-detect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
"integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -13474,6 +14052,89 @@
"@esbuild/win32-x64": "0.21.5" "@esbuild/win32-x64": "0.21.5"
} }
}, },
"node_modules/vitest": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
"integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "1.6.0",
"@vitest/runner": "1.6.0",
"@vitest/snapshot": "1.6.0",
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
"execa": "^8.0.1",
"local-pkg": "^0.5.0",
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"tinybench": "^2.5.1",
"tinypool": "^0.8.3",
"vite": "^5.0.0",
"vite-node": "1.6.0",
"why-is-node-running": "^2.2.2"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.6.0",
"@vitest/ui": "1.6.0",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vitest/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -13607,6 +14268,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wide-align": { "node_modules/wide-align": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",

View file

@ -18,7 +18,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepare": "husky install", "prepare": "husky install",
"commit": "git-cz", "commit": "git-cz",
"bump": "node scripts/update-version.js" "bump": "node scripts/update-version.js",
"test": "vitest run"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint", "*.{ts,tsx,js,jsx}": "eslint",
@ -87,7 +88,10 @@
"slate-dom": "0.123.0", "slate-dom": "0.123.0",
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.123.0", "slate-react": "0.123.0",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35",
"@fontsource/ubuntu": "5.2.5",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ibm-plex-mono": "5.2.5"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@element-hq/element-call-embedded": "0.20.1",
@ -123,6 +127,7 @@
"vite": "5.4.19", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4",
"vitest": "1.6.0"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 817 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,19 +0,0 @@
<!-- 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>

Before

Width:  |  Height:  |  Size: 871 B

View file

@ -0,0 +1,17 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,17 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,15 @@
<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>

After

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

202
scripts/gen-brand.mjs Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
/** On phones every control inside the navigator is at least 44 px square to tap. */
export const Targets = style({});
globalStyle(`${Targets} button, ${Targets} a`, {
'@media': {
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
minHeight: 44,
minWidth: 44,
},
},
});

View file

@ -0,0 +1,326 @@
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
} from '../../components/nav';
import { UseStateProvider } from '../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../components/join-address-prompt';
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar';
import {
encodeSearchParamValueArray,
getDirectRoomPath,
getHomeCreatePath,
getHomeRoomPath,
getSpaceRoomPath,
withSearchParam,
} from '../../pages/pathUtils';
import { _RoomSearchParams } from '../../pages/paths';
import { searchModalAtom } from '../../state/searchModal';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
import { makeNavCategoryId } from '../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { EstateRoom, EstateTier } from './buildEstate';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
import * as css from './EstateNavigator.css';
/** How long a tap waits for the client store to hold the room it just joined. */
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
const estateRoomPath = (mx: ReturnType<typeof useMatrixClient>, room: EstateRoom): string => {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
if (room.direct) return getDirectRoomPath(alias);
// A Space the user has not joined has no Space route; the room still resolves from Home.
if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') {
return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias);
}
return getHomeRoomPath(alias);
};
function JoinRow({ room }: { room: EstateRoom }) {
const mx = useMatrixClient();
const navigate = useNavigate();
const [state, join] = useAsyncCallback(
useCallback(async () => {
const result = await activateEstateRoom(mx, room);
if (result.kind === 'refused') throw new Error(result.message);
// joinRoom resolves before the store holds the room; opening it now shows the invite card.
if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) {
// The join is accepted; when the room arrives the row becomes a joined row by itself.
throw new Error('Joined. The room is still on its way and will open from this list.');
}
navigate(estateRoomPath(mx, room));
}, [mx, navigate, room])
);
const busy = state.status === AsyncStatus.Loading;
return (
<NavItem variant="Background" radii="400">
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
<NavItemContent>
<Text as="span" size="Inherit" truncate>
{room.name}
</Text>
</NavItemContent>
{state.status === AsyncStatus.Error && (
<Text
as="span"
size="T200"
style={{ color: color.Critical.Main, overflowWrap: 'anywhere' }}
>
{(state.error as Error).message}
</Text>
)}
<Chip
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
fill="Soft"
radii="Pill"
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
onClick={join}
disabled={busy}
aria-label={`Join ${room.name}`}
>
<Text size="B300">Join</Text>
</Chip>
</Box>
</NavItem>
);
}
function EstateRow({ room }: { room: EstateRoom }) {
const mx = useMatrixClient();
const selectedRoomId = useSelectedRoom();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null;
if (!mxRoom) return <JoinRow room={room} />;
return (
<RoomNavItem
room={mxRoom}
selected={selectedRoomId === room.roomId}
direct={room.direct}
linkPath={estateRoomPath(mx, room)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
}
type CollapsibleProps = {
categoryId: string;
label: string;
rooms: EstateRoom[];
children: ReactNode;
};
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const selectedRoomId = useSelectedRoom();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const callRoomId = useCallEmbed()?.roomId;
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
const active = rooms.some(
(r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId)
);
// Phones start collapsed unless the section holds something live; wide screens start open.
// A tap flips that default; the flip is remembered per device and per screen class.
const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId);
const closed = (mobile && !active) !== toggled.has(key);
const handleClick: MouseEventHandler<HTMLButtonElement> = () =>
setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
{label}
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closed && children}
</NavCategory>
);
}
function Tier({ tier }: { tier: EstateTier }) {
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
return (
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
{tier.loose.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
{tier.groups.map((g) => (
<Collapsible
key={g.mind}
categoryId={`${tier.id}|${g.mind}`}
label={g.label}
rooms={g.rooms}
>
{g.rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
</Collapsible>
))}
</Collapsible>
);
}
function EntryPoints() {
const navigate = useNavigate();
return (
<NavCategory>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</>
)}
</UseStateProvider>
</NavCategory>
);
}
/** A short message that wraps inside the navigator; never a raw request error. */
function EstateError({ message }: { message: string }) {
return (
<Text size="T300" style={{ color: color.Critical.Main, padding: 16, overflowWrap: 'anywhere' }}>
{message}
</Text>
);
}
function EstateTiers({ rootId }: { rootId: string }) {
const { fetching, error, tiers } = useEstate(rootId);
return (
<>
{tiers.map((tier) => (
<Tier key={tier.id} tier={tier} />
))}
{fetching && (
<Box justifyContent="Center" style={{ padding: 16 }}>
<Spinner />
</Box>
)}
{error && (
<EstateError message="Could not load the room list. Check your connection and reload." />
)}
</>
);
}
/** The single navigator: every nav slot that lists rooms mounts this. */
export function EstateNavigator() {
const openRoomFinder = useSetAtom(searchModalAtom);
const { rootId, error } = useEstateRootId();
const scrollRef = useRef<HTMLDivElement>(null);
const mx = useMatrixClient();
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
return (
<PageNav>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300" className={css.Targets}>
<Box grow="Yes">
<Text size="H4" truncate>
{title}
</Text>
</Box>
<IconButton
variant="Background"
aria-label="Find a room"
onClick={() => openRoomFinder(true)}
>
<Icon src={Icons.Search} size="200" />
</IconButton>
</Box>
</PageNavHeader>
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300" className={css.Targets}>
<EntryPoints />
{rootId && <EstateTiers rootId={rootId} />}
{!rootId && error && (
<EstateError message="Could not find the GameCult space. Check your connection and reload." />
)}
{!rootId && !error && (
<Box justifyContent="Center" style={{ padding: 16 }}>
<Spinner />
</Box>
)}
</Box>
</PageNavContent>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
gap="200"
style={{ padding: 8 }}
className={css.Targets}
>
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</Box>
</PageNav>
);
}

View file

@ -0,0 +1,326 @@
import { describe, expect, it } from 'vitest';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate';
const ROOT = '!root:gamecult.org';
const PROJECTS = '!projects:gamecult.org';
/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */
const space = (
id: string,
name: string,
children: string[],
stewards: Record<string, string> = {}
): EstateHierarchyRoom => ({
room_id: id,
name,
room_type: 'm.space',
children_state: children.map((c) => ({
state_key: c,
content: {
via: ['gamecult.org'],
...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}),
},
})),
});
const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({
room_id: id,
name,
canonical_alias: alias,
});
const hierarchy = [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]),
room('!general:gamecult.org', 'General', '#general:gamecult.org'),
space(
PROJECTS,
'Projects',
['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'],
{ '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' }
),
room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'),
room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'),
room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'),
];
const input = (over: Partial<EstateInput> = {}): EstateInput => ({
rootId: ROOT,
hierarchy,
membership: new Map(),
directRoomIds: [],
...over,
});
const find = (tiers: ReturnType<typeof buildEstate>, roomId: string) =>
tiers
.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])
.find((r) => r.roomId === roomId);
describe('buildEstate', () => {
it('lists an invited project room as invited', () => {
const tiers = buildEstate(
input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })
);
expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited');
});
it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => {
const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) }));
expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined');
expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable');
expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']);
});
it('omits a room absent from the hierarchy even when the client store knows it', () => {
const hidden = '!estate-secret:gamecult.org';
const tiers = buildEstate(
input({
membership: new Map([[hidden, 'join']]),
// The root names the room as a child, but the server did not return it.
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]),
...hierarchy.slice(1),
],
})
);
expect(find(tiers, hidden)).toBeUndefined();
});
it('lists every room the server returned, whatever the user may join', () => {
const tiers = buildEstate(input());
expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4);
});
it('groups rooms by steward Mind from the steward on each Space child entry', () => {
const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([
['eureka', ['!cultlib:gamecult.org']],
['void', ['!aeth:gamecult.org']],
]);
});
it('sorts steward groups by display label', () => {
const projects = buildEstate(
input({
mindLabels: new Map([
['eureka', 'Zed'],
['void', 'Alpha'],
]),
})
).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']);
});
it('lists a room with no alias match loose in its tier', () => {
const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!;
expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']);
});
it('groups an invited-only user by the same child-entry stewards, with no root state', () => {
const invitee = new Map<string, 'join' | 'invite'>([
[ROOT, 'invite'],
[PROJECTS, 'invite'],
['!cultlib:gamecult.org', 'invite'],
['!aeth:gamecult.org', 'invite'],
]);
const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([
['eureka', ['invited']],
['void', ['invited']],
]);
});
it('ignores a steward that is not a non-empty string', () => {
const odd = (steward: unknown): EstateHierarchyRoom[] => [
{
...space(ROOT, 'GameCult', ['!a:gamecult.org']),
children_state: [
{ state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } },
],
},
room('!a:gamecult.org', 'A'),
];
[42, '', undefined, null].forEach((m) => {
const t = buildEstate(input({ hierarchy: odd(m) }))[0];
expect(t.groups).toEqual([]);
expect(t.loose).toHaveLength(1);
});
});
it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => {
const second = '!second:gamecult.org';
const tiers = buildEstate(
input({
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]),
...hierarchy.slice(1),
space(second, 'Workshop', ['!w1:gamecult.org']),
room('!w1:gamecult.org', 'Bench'),
],
directRoomIds: ['!dm:gamecult.org'],
membership: new Map([['!dm:gamecult.org', 'join']]),
})
);
expect(tiers.map((t) => t.label)).toEqual([
'GameCult',
'Projects',
'Workshop',
'Direct Messages',
]);
expect(tiers[3].loose[0].direct).toBe(true);
expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']);
expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null });
});
it('takes join servers from the matching children_state entry', () => {
const tiers = buildEstate(
input({
hierarchy: [
{
...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']),
children_state: [
{ state_key: '!a:gamecult.org', content: { via: ['one.example'] } },
{ state_key: '!b:gamecult.org', content: { via: ['two.example'] } },
],
},
room('!a:gamecult.org', 'A'),
room('!b:gamecult.org', 'B'),
],
})
);
expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']);
expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']);
});
it('puts every room of one Mind in a single group', () => {
const tiers = buildEstate(
input({
hierarchy: [
...hierarchy.slice(0, 2),
space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], {
'!cultlib:gamecult.org': 'eureka',
'!aeth:gamecult.org': 'eureka',
}),
...hierarchy.slice(3, 5),
],
})
);
const projects = tiers.find((t) => t.id === PROJECTS)!;
expect(projects.groups).toHaveLength(1);
expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([
'!cultlib:gamecult.org',
'!aeth:gamecult.org',
]);
});
it('sorts group labels without regard to case', () => {
const projects = buildEstate(
input({
mindLabels: new Map([
['eureka', 'beta'],
['void', 'Alpha'],
]),
})
).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']);
});
it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => {
const tiers = buildEstate(
input({
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]),
room('!general:gamecult.org', 'General'),
space(PROJECTS, 'Projects', ['!hidden:gamecult.org']),
],
})
);
expect(tiers.map((t) => t.id)).toEqual([ROOT]);
});
it('puts direct messages in the final tier, off any Space', () => {
const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] }));
expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] });
});
it('is identical for identical inputs', () => {
expect(buildEstate(input())).toEqual(buildEstate(input()));
});
});
describe('stewardOfChild', () => {
it('reads the Mind from the org.gamecult.steward key of the child content', () => {
expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void');
});
it('has no steward without the key, content or a string Mind', () => {
expect(stewardOfChild(undefined)).toBeUndefined();
expect(stewardOfChild({ via: ['x'] })).toBeUndefined();
expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined();
});
});
describe('Elsewhere', () => {
const strays = (over: Partial<EstateInput>) =>
buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find(
(t) => t.id === 'elsewhere'
);
it('lists a joined non-DM room the hierarchy does not return, named from the store', () => {
const t = strays({
strayRoomIds: ['!adhoc:x'],
roomNames: new Map([['!adhoc:x', 'Meeting']]),
})!;
expect(t.label).toBe('Elsewhere');
expect(t.loose).toEqual([
{
roomId: '!adhoc:x',
name: 'Meeting',
state: 'joined',
via: [],
parentId: null,
direct: false,
},
]);
});
it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => {
const tiers = buildEstate(
input({
membership: new Map([
['!general:gamecult.org', 'join'],
['!dm:x', 'join'],
['!adhoc:x', 'join'],
]),
strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'],
directRoomIds: ['!dm:x'],
})
);
expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']);
expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']);
});
it('never lists a room the user has not joined', () => {
expect(
strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })
).toBeUndefined();
});
it('is absent when there are no strays', () => {
expect(strays({})).toBeUndefined();
});
});
describe('direct invites', () => {
it('lists an invited DM in the Direct tier as invited, named from the store', () => {
const tiers = buildEstate(
input({
membership: new Map([['!dm:x', 'invite']]),
directRoomIds: ['!dm:x'],
roomNames: new Map([['!dm:x', 'Void']]),
})
);
expect(tiers[tiers.length - 1].loose).toEqual([
{ roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true },
]);
});
});

View file

@ -0,0 +1,186 @@
/**
* The estate index: the only code that decides which rooms the navigator lists,
* in which tier and steward group, and whether each is joined, invited or joinable.
*
* The server's /hierarchy of the root Space is the access rule. A room the
* hierarchy does not return is never listed, whatever the client store knows;
* a room it returns is never hidden by the client. The one licensed addition is
* the user's own joined rooms that sit outside the Space (the Elsewhere tier).
*
* A room's steward is read from the hierarchy too: the content of the
* m.space.child entry that lists the room carries org.gamecult.steward, which an
* invited-only member can read (she cannot read the Space's state).
*/
export const STEWARD_KEY = 'org.gamecult.steward';
export type SpaceChildContent = {
via?: string[];
/** The Mind who stewards the room, as the accounts script writes it. */
[STEWARD_KEY]?: { mind?: unknown };
};
export type EstateHierarchyRoom = {
room_id: string;
name?: string;
canonical_alias?: string;
room_type?: string;
children_state?: { state_key: string; content?: SpaceChildContent }[];
};
export type EstateMembership = 'joined' | 'invited' | 'joinable';
export type EstateRoom = {
roomId: string;
name: string;
state: EstateMembership;
/** Servers to join through, from the hierarchy's children_state. */
via: string[];
/** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */
parentId: string | null;
direct: boolean;
};
export type EstateGroup = {
mind: string;
label: string;
rooms: EstateRoom[];
};
export type EstateTier = {
id: string;
label: string;
/** Rooms without a steward. */
loose: EstateRoom[];
groups: EstateGroup[];
};
export type EstateInput = {
rootId: string;
/** Rooms from the server's hierarchy of the root, in hierarchy order. */
hierarchy: readonly EstateHierarchyRoom[];
/** The user's own membership per room id, from the client store. */
membership: ReadonlyMap<string, 'join' | 'invite'>;
/** Joined and invited direct messages. */
directRoomIds: readonly string[];
/** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */
strayRoomIds?: readonly string[];
/** Names for rooms the hierarchy does not name (direct and stray rooms). */
roomNames?: ReadonlyMap<string, string>;
/** Display names for Minds, keyed by localpart. */
mindLabels?: ReadonlyMap<string, string>;
};
export const DIRECT_TIER_ID = 'direct';
export const ELSEWHERE_TIER_ID = 'elsewhere';
const SPACE_TYPE = 'm.space';
/** The Mind a Space child entry names as the room's steward, if any. */
export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => {
const mind = content?.[STEWARD_KEY]?.mind;
return typeof mind === 'string' && mind !== '' ? mind : undefined;
};
const stateOf = (
roomId: string,
membership: ReadonlyMap<string, 'join' | 'invite'>
): EstateMembership => {
const m = membership.get(roomId);
if (m === 'join') return 'joined';
if (m === 'invite') return 'invited';
return 'joinable';
};
const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id;
export const buildEstate = ({
rootId,
hierarchy,
membership,
directRoomIds,
strayRoomIds = [],
roomNames,
mindLabels,
}: EstateInput): EstateTier[] => {
const direct = new Set(directRoomIds);
const byId = new Map(hierarchy.map((r) => [r.room_id, r]));
const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE);
const root = byId.get(rootId);
// Root first, then every sub-Space in hierarchy order.
const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces;
const tiers: EstateTier[] = tierSpaces.map((space) => {
const childIds = new Set((space.children_state ?? []).map((c) => c.state_key));
const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] };
const groups = new Map<string, EstateGroup>();
hierarchy.forEach((r) => {
if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return;
const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content;
const room: EstateRoom = {
roomId: r.room_id,
name: roomLabel(r),
state: stateOf(r.room_id, membership),
via: content?.via ?? [],
parentId: space.room_id,
direct: false,
};
const mind = stewardOfChild(content);
if (!mind) {
tier.loose.push(room);
return;
}
let group = groups.get(mind);
if (!group) {
group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] };
groups.set(mind, group);
}
group.rooms.push(room);
});
tier.groups = [...groups.values()].sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
);
return tier;
});
const directTier: EstateTier = {
id: DIRECT_TIER_ID,
label: 'Direct Messages',
loose: directRoomIds.map((roomId) => ({
roomId,
name: roomNames?.get(roomId) ?? roomId,
state: stateOf(roomId, membership),
via: [],
parentId: null,
direct: true,
})),
groups: [],
};
const listedIds = new Set(hierarchy.map((r) => r.room_id));
const elsewhereTier: EstateTier = {
id: ELSEWHERE_TIER_ID,
label: 'Elsewhere',
loose: strayRoomIds
.filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join')
.map((roomId) => ({
roomId,
name: roomNames?.get(roomId) ?? roomId,
state: 'joined' as const,
via: [],
parentId: null,
direct: false,
})),
groups: [],
};
const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0);
return [
...listed,
...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []),
...(directTier.loose.length > 0 ? [directTier] : []),
];
};

View file

@ -0,0 +1,9 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
describe('repo config.json', () => {
it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => {
const config = JSON.parse(readFileSync('config.json', 'utf8'));
expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org');
});
});

View file

@ -0,0 +1 @@
export * from './EstateNavigator';

View file

@ -0,0 +1,352 @@
import { describe, expect, it, vi } from 'vitest';
import {
activateEstateRoom,
awaitMyJoin,
joinEstateRoomIdOrAlias,
joinEstateRoomOrThrow,
runJoinCommand,
} from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
state,
via: ['gamecult.org'],
});
/** A client that records every call; anything but joinRoom is a peek or a side door. */
const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => {
const calls: string[] = [];
const mx = new Proxy({} as Record<string, unknown>, {
get: (_t, prop: string) => {
if (prop === 'joinRoom') {
return (...args: unknown[]) => {
calls.push('joinRoom');
return joinRoom(...args);
};
}
if (prop === 'getRoom') return () => null;
if (prop === 'getSafeUserId') return () => '@me:test';
return () => {
calls.push(prop);
throw new Error(`unexpected client call: ${prop}`);
};
},
});
return { mx: mx as never, calls, joinRoom };
};
describe('activateEstateRoom', () => {
it('opens a joined room without touching the client', async () => {
const { mx, calls } = recordingClient();
expect(await activateEstateRoom(mx, room('joined'))).toEqual({
kind: 'open',
roomId: '!r:gamecult.org',
});
expect(calls).toEqual([]);
});
it.each(['invited', 'joinable'] as const)(
'joins a %s room on the tap, through the hierarchy servers, and nothing else',
async (state) => {
const { mx, calls, joinRoom } = recordingClient();
const result = await activateEstateRoom(mx, room(state));
expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' });
expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] });
expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path
}
);
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' })
)
);
expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Boom',
});
const bare = recordingClient(
vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })
);
expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Could not join',
});
});
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));
const result = await activateEstateRoom(mx, room('joinable'));
expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' });
expect(joinRoom).toHaveBeenCalledTimes(1);
});
});
/** 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>;
}) => {
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: {
getContent: () => ({ membership: 'invite', is_direct: true }),
getSender: () => opts.invites[roomId],
},
},
}),
}),
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);
// 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.drop) throw new Error('fetch failed');
return { roomId };
},
};
return { mx: mx as never, writes, joined, joinOpts, current: () => direct };
};
const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] });
describe('activateEstateRoom for a direct message invite', () => {
it('lists the room under the inviter before the join is sent', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('keeps both rooms when two DM joins are tapped together', async () => {
const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } });
const results = await Promise.all([
activateEstateRoom(c.mx, tap('!dm2:test')),
activateEstateRoom(c.mx, tap('!dm3:test')),
]);
expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']);
expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] });
});
it('takes the room out again when the join is refused', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' });
expect(c.current()).toEqual({ '@friend:test': [] });
});
});
describe('activateEstateRoom when the join gets no answer', () => {
it('keeps the room listed and shows a retryable message', async () => {
const c = dmClient({ drop: true, 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'] });
});
});
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 = () => {
let membership: string | null = null;
const handlers = new Set<(room: { roomId: string }, m: string) => void>();
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);
},
},
handlers,
become: (roomId: string, m: string) => {
membership = m;
handlers.forEach((h) => h({ roomId }, m));
},
};
};
it('resolves at once when the store already holds the joined room', async () => {
const s = store();
s.become('!r:x', 'join');
expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true);
});
it('stays pending while the store still shows an invite, then resolves on join', async () => {
const s = store();
let settled = false;
const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => {
settled = true;
return v;
});
s.become('!r:x', 'invite');
s.become('!other:x', 'join');
await Promise.resolve();
expect(settled).toBe(false);
s.become('!r:x', 'join');
expect(await p).toBe(true);
expect(s.handlers.size).toBe(0);
});
it('gives up after the timeout and stops listening', async () => {
vi.useFakeTimers();
const s = store();
const p = awaitMyJoin(s.mx, '!r:x', 500);
await vi.advanceTimersByTimeAsync(500);
expect(await p).toBe(false);
expect(s.handlers.size).toBe(0);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,151 @@
import { EstateRoom } from './buildEstate';
import {
addRoomIdToMDirect,
isRoomAlias,
isRoomId,
removeRoomIdFromMDirect,
} from '../../utils/matrix';
import { splitWithSpace } from '../../utils/common';
import { isDirectInvite } from '../../utils/room';
export type EstateActivation =
| { kind: 'open'; roomId: string }
| { kind: 'joined'; roomId: string }
| { kind: 'refused'; message: string };
const LOST_JOIN_RESPONSE =
'The server did not answer the join. It may have gone through; tap again to retry.';
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
/**
* What a tap on an estate row does. A joined room opens; any other room is
* joined, and only here (plus a first post in the room view). Opening a room
* never joins it and the client has no peek path. The server decides whether
* the join is allowed; its refusal is reported, the row is not hidden.
*
* 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.
*/
export const activateEstateRoom = async (
mx: JoinClient,
room: Pick<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
): 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 invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null;
const inviter = isDirectInvite(invite, mx.getSafeUserId())
? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender()
: undefined;
try {
if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter);
} catch (e) {
return refusal(e);
}
try {
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 (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;
off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown;
};
/**
* joinRoom resolves when the server accepts the join, before the client store
* holds the room; navigating then lands on the invite card. Resolves true once
* the store says the user's membership is join, false after timeoutMs.
*/
export const awaitMyJoin = (
mx: MembershipSource,
roomId: string,
timeoutMs: number
): Promise<boolean> =>
new Promise((resolve) => {
if (mx.getRoom(roomId)?.getMyMembership() === 'join') {
resolve(true);
return;
}
let finish: (joined: boolean) => void = () => undefined;
const onMembership = (room: { roomId: string }, membership: string) => {
if (room.roomId === roomId && membership === 'join') finish(true);
};
const timer = setTimeout(() => finish(false), timeoutMs);
finish = (joined) => {
clearTimeout(timer);
mx.off('Room.myMembership', onMembership);
resolve(joined);
};
mx.on('Room.myMembership', onMembership);
});

View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy';
import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { mDirectAtom } from '../../state/mDirectList';
import { StateEvent } from '../../../types/matrix/room';
import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate';
const HIERARCHY_DEPTH = 10;
type AliasedRoom = {
roomId: string;
getCanonicalAlias(): string | null;
getAltAliases(): string[];
};
const findLoadedRoom = (
mx: ReturnType<typeof useMatrixClient>,
alias: string
): string | undefined =>
(mx.getRooms() as AliasedRoom[]).find(
(r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias)
)?.roomId;
/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */
export const useEstateRootId = (): { rootId?: string; error?: Error } => {
const mx = useMatrixClient();
const alias = useClientConfig().featuredCommunities?.spaces?.[0];
const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias);
const resolved = useQuery({
queryKey: ['estate-root', alias],
queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id,
enabled: !!alias && !loaded,
});
if (!alias) return { error: new Error('No estate root Space is configured') };
return { rootId: loaded || resolved.data, error: resolved.error ?? undefined };
};
export type Estate = {
fetching: boolean;
error: Error | null;
tiers: EstateTier[];
};
/**
* Feeds buildEstate: the server hierarchy of the root Space (which also carries
* each room's steward), the user's membership, m.direct and direct invites, and
* her joined rooms outside the Space. Recomputes on membership and Space child changes.
*/
export const useEstate = (rootId: string): Estate => {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH);
const joined = useAtomValue(allRoomsAtom);
const invited = useAtomValue(allInvitesAtom);
const mDirects = useAtomValue(mDirectAtom);
const refetchHierarchy = useCallback(
() => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }),
[queryClient, rootId]
);
useStateEventCallback(
mx,
useCallback(
(event) => {
if (event.getType() === StateEvent.SpaceChild) refetchHierarchy();
},
[refetchHierarchy]
)
);
// What the server lets this user see changes with invitations.
useEffect(() => {
refetchHierarchy();
}, [invited.length, refetchHierarchy]);
const tiers = useMemo(() => {
const membership = new Map<string, 'join' | 'invite'>();
invited.forEach((id) => membership.set(id, 'invite'));
joined.forEach((id) => membership.set(id, 'join'));
const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[];
const domain = mx.getDomain();
const mindLabels = new Map<string, string>();
hierarchy.forEach((r) =>
r.children_state?.forEach((c) => {
const mind = stewardOfChild(c.content);
const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName;
if (mind && name) mindLabels.set(mind, name);
})
);
const userId = mx.getUserId();
const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId));
const directRoomIds = [
...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]),
];
const strayRoomIds = joined.filter((id) => {
const r = mx.getRoom(id);
return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r);
});
const roomNames = new Map<string, string>();
[...directInvites, ...strayRoomIds].forEach((id) => {
const name = mx.getRoom(id)?.name;
if (name) roomNames.set(id, name);
});
return buildEstate({
rootId,
hierarchy,
membership,
directRoomIds,
strayRoomIds,
roomNames,
mindLabels,
});
}, [mx, rootId, rooms, joined, invited, mDirects]);
return { fetching, error, tiers };
};

View file

@ -200,16 +200,12 @@ type HierarchyItemMenuProps = {
joined: boolean; joined: boolean;
powerLevels?: IPowerLevels; powerLevels?: IPowerLevels;
canEditChild: boolean; canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
}; };
export function HierarchyItemMenu({ export function HierarchyItemMenu({
item, item,
joined, joined,
powerLevels, powerLevels,
canEditChild, canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) { }: HierarchyItemMenuProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}> <Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
{joined && ( {joined && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{onTogglePin && (
<MenuItem
size="300"
radii="300"
onClick={() => {
onTogglePin(item.roomId);
handleRequestClose();
}}
>
<Text as="span" size="T300" truncate>
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
</Text>
</MenuItem>
)}
<InviteMenuItem <InviteMenuItem
item={item} item={item}
requestClose={handleRequestClose} requestClose={handleRequestClose}

View file

@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
sidebarItemWithout,
useSidebarItems,
} from '../../hooks/useSidebarItems';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy'; import { SpaceHierarchy } from './SpaceHierarchy';
import { useGetRoom } from '../../hooks/useGetRoom'; import { useGetRoom } from '../../hooks/useGetRoom';
@ -167,18 +159,6 @@ export function Lobby() {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const [onTop, setOnTop] = useState(true); const [onTop, setOnTop] = useState(true);
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom()); const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
const [sidebarItems] = useSidebarItems(
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
);
const sidebarSpaces = useMemo(() => {
const sideSpaces = sidebarItems.flatMap((item) => {
if (typeof item === 'string') return item;
return item.content;
});
return new Set(sideSpaces);
}, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map()); const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver( useElementSizeObserver(
@ -415,18 +395,6 @@ export function Lobby() {
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
}; };
const togglePinToSidebar = useCallback(
(rId: string) => {
const newItems = sidebarItemWithout(sidebarItems, rId);
if (!sidebarSpaces.has(rId)) {
newItems.push(rId);
}
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
},
[mx, sidebarItems, sidebarSpaces]
);
return ( return (
<PowerLevelsContextProvider value={spacePowerLevels}> <PowerLevelsContextProvider value={spacePowerLevels}>
<Box grow="Yes"> <Box grow="Yes">
@ -499,8 +467,6 @@ export function Lobby() {
disabledReorder={reordering} disabledReorder={reordering}
nextSpaceId={nextSpaceId} nextSpaceId={nextSpaceId}
getRoom={getRoom} getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
togglePinToSidebar={togglePinToSidebar}
onSpacesFound={handleSpacesFound} onSpacesFound={handleSpacesFound}
onOpenRoom={handleOpenRoom} onOpenRoom={handleOpenRoom}
/> />

View file

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

View file

@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
disabledReorder?: boolean; disabledReorder?: boolean;
nextSpaceId?: string; nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined; getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom: MouseEventHandler<HTMLButtonElement>; onOpenRoom: MouseEventHandler<HTMLButtonElement>;
}; };
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
disabledReorder, disabledReorder,
nextSpaceId, nextSpaceId,
getRoom, getRoom,
pinned,
togglePinToSidebar,
onOpenRoom, onOpenRoom,
onSpacesFound, onSpacesFound,
}, },
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
canEditChild={ canEditChild={
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
} }
pinned={pinned}
onTogglePin={togglePinToSidebar}
/> />
) )
} }

View file

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

View file

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

View file

@ -0,0 +1,79 @@
# 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

@ -0,0 +1,443 @@
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

@ -0,0 +1,638 @@
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

@ -0,0 +1,41 @@
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

@ -0,0 +1,59 @@
// 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

@ -0,0 +1,42 @@
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

@ -0,0 +1,56 @@
// 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

@ -0,0 +1,27 @@
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

@ -0,0 +1,37 @@
// 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

@ -0,0 +1,82 @@
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

@ -0,0 +1,81 @@
/**
* 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

@ -0,0 +1,33 @@
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

@ -0,0 +1,27 @@
// 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

@ -0,0 +1,41 @@
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

@ -0,0 +1,99 @@
// 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

@ -0,0 +1,19 @@
# 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

@ -0,0 +1,90 @@
// 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

@ -0,0 +1,31 @@
// 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

@ -0,0 +1,26 @@
// 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

@ -0,0 +1,8 @@
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

@ -0,0 +1,57 @@
// 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

@ -0,0 +1,41 @@
// 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

@ -0,0 +1,95 @@
// 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

@ -0,0 +1,108 @@
// 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

@ -0,0 +1,26 @@
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

@ -0,0 +1,49 @@
// 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

@ -0,0 +1,40 @@
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

@ -0,0 +1,117 @@
// 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 { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import CinnySVG from '../../../../../public/res/svg/cinny.svg'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
<Box shrink="No"> <Box shrink="No">
<img <img
style={{ width: toRem(60), height: toRem(60) }} style={{ width: toRem(60), height: toRem(60) }}
src={CinnySVG} src={RatatoskrSVG}
alt="Cinny logo" alt="Ratatoskr logo"
/> />
</Box> </Box>
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Box gap="100" alignItems="End"> <Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text> <Text size="H3">Ratatoskr</Text>
<Text size="T200">v4.12.8</Text> <Text size="T200">v4.12.8</Text>
</Box> </Box>
<Text>Yet another matrix client.</Text> <Text>GameCult&apos;s Matrix client.</Text>
</Box> </Box>
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
<Button <Button
as="a" as="a"
href="https://github.com/cinnyapp/cinny" href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
rel="noreferrer noopener" rel="noreferrer noopener"
target="_blank" target="_blank"
variant="Secondary" variant="Secondary"
@ -65,20 +65,19 @@ export function About({ requestClose }: AboutProps) {
> >
<Text size="B300">Source Code</Text> <Text size="B300">Source Code</Text>
</Button> </Button>
<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 />}
>
<Text size="B300">Support</Text>
</Button>
</Box> </Box>
<Text size="T300">
Based on{' '}
<a
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
>
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>
</Box> </Box>
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">

View file

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

View file

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

View file

@ -0,0 +1,12 @@
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

@ -0,0 +1,108 @@
// 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

@ -0,0 +1,8 @@
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

@ -0,0 +1,47 @@
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,
};
};

Some files were not shown because too many files have changed in this diff Show more