Skip to content

feat(loading): bounded priority-ordered boot fetch + body-texture atlas#505

Merged
rulkens merged 30 commits into
mainfrom
worktree-boot-load-priority
Jul 24, 2026
Merged

feat(loading): bounded priority-ordered boot fetch + body-texture atlas#505
rulkens merged 30 commits into
mainfrom
worktree-boot-load-priority

Conversation

@rulkens

@rulkens rulkens commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Boots at Earth but fetched ~101.7 MB of catalogs with no ordering and no
concurrency bound, so the first view took far longer than it should. This
introduces a bounded, priority-ordered fetch scheduler plus a ≤1 MB body-texture
atlas that gives every body a low-res placeholder while its hi-res map loads.

What lands

  • Bounded priority queue at the fetch layer. reevaluateDemand no longer
    fires slot.load() per demanded row; it collects the whole demand batch and
    submits it through state.subsystems.assetQueue (ASSET_QUEUE_CONCURRENCY = 2).
    The queue starts at most N fetches, ordered by each row's authored priority,
    and re-runs safely on every state change via its dedup semantics.
  • Authored priority per ASSET_WIRING row — one integer per asset, lower
    fetched first. Body atlas 0 → hi-res body textures 10 → famous/meta 20s →
    structure/constellations 30s → 2MRS 40 → stars 50 → the megaparsec catalogs
    60s → singleton volumes 70–82 → pgc aliases 90.
  • Body-texture atlas. build-textures emits a 2048×1024 / ~161 KB WebP with
    13 tiles; a dedicated slot fetches it first and fans each tile out as a cropped
    placeholder (via copyExternalImageToTexture origin, a transport-only crop —
    no shader/BGL/UV changes). Two-layer resolution (committed ?? placeholder)
    guarantees a late atlas tile never clobbers a landed hi-res map.
  • Head-of-queue fix. The whole demand batch is ranked before any fetch
    starts (enqueueMany), so the first N starts are the N best-ranked demanded
    rows, not the first N walked. Without it, array-position rows (e.g. sdss-points
    at rank 60) grabbed a boot pipe at t=0 ahead of starCatalog:gaiaStars.
  • Debug panel shows each asset's fetch rank and start/done timing.

Known gaps (captured in docs/backlog, deliberately out of scope)

  • Tier-swap reloads bypass the queue. makeRunTierTransition calls
    slot.load() directly per source, so a zoom-driven tier change reverts to
    unbounded, array-ordered fetching. Body-texture tier changes DO go through the
    queue (via staleTierEvict → release → re-enqueue).
  • Visibility/scale gate. The megaparsec catalogs gate on enabled only, so
    they load at boot even though they aren't visible from the Earth home view.
    A scale-band demand gate (extending the bodyTextureRow proximity pattern) is
    the deeper lever and will be designed with the powers-of-ten scale-ladder work.
  • N=1 vs N=2. Over a single HTTP/2 connection, N=2 lets the top asset
    transfer at half speed while sharing with lower-ranked heavies. Left at N=2 for
    now; a weight-aware bound is the eventual answer.

Verification

  • npm run typecheck clean (both tsconfigs)
  • npm test — 867 files / 5026 tests passing
  • New tests go RED-first against pre-fix behaviour for the head-of-queue change
    (batch ranking) and the queue's per-instance bound.

Manual throttled cold-cache waterfall verification and the three visual
placeholder checks (Mars/Jupiter/Earth tile orientation) are pending a local
build.

🤖 Generated with Claude Code

rulkens and others added 29 commits July 24, 2026 19:14
The load-order grill surfaced work that is adjacent to the boot
scheduler but deliberately outside it. Capturing each so the spec can
stay scoped to the delta it actually needs:

- scale-gated asset demand: ~68 MB of the ~101.7 MB boot fetch renders
  nothing at the Earth boot view, because surveyDeepZoom fades glade,
  sdss, milliquas and mcpm to zero below 0.002 Mpc. Worth more than the
  scheduler itself, so it gets its own design.
- filaments + flow lack SCALE_FADE_BANDS rows, which blocks their
  participation in the gate above.
- famous_stars_meta.json fetches at React mount outside ASSET_WIRING,
  though it is only needed when a star InfoCard opens.
- jupiter/saturn 404 on the large texture tier: the registry declares a
  ceiling for files that do not exist on disk.
- dead files in public/data/.
- body-texture store consolidation: four renderers each hand-roll map
  storage and placeholder fallback.

Also narrows the existing asset-loading audit item: its "what loads,
when, and how big" half is discharged by the grill transcript, leaving
the eviction policy and the debug UI sweep.
A cold boot fetches ~101.7 MB at default settings with no ordering and
no concurrency limit, so every asset starves every other and the first
view takes far longer than it should. The loading system was designed
when the app had no stars and no textured planets.

The design is three pieces:

- a bounded-concurrency priority queue at the AssetSlot fetch layer,
  reusing PriorityQueue, N=2, never preempting. The bound IS the
  mechanism: under HTTP/2 every request fires at once and shares
  bandwidth, so reordering without a limit changes nothing.
- one static priority integer per ASSET_WIRING row, payload size folded
  in by the author. No positional input at all.
- a single sRGB atlas of 512x256 surface tiles for the 13 registry
  bodies, cropped into the existing per-body GPU textures at upload. The
  atlas is a transport format, not a sampling format, so no shader,
  binding or uniform changes.

The atlas is what lets the scheduler stay simple: a universal low-res
set means no body is ever untextured, which removes the need to predict
where the camera is heading and deletes the destination-tracking the
design originally carried.

Includes the 20-question grill transcript with the rejected
alternatives, and a Ground preparation section recording the
refactor-ground checkpoint: four prep refactors (P1-P4) sequenced
before the feature commits, all on one PR.
Sequences the spec into commit-sized tasks. Part 1 carries the four prep
refactors and the bounded queue; part 2 the body-texture atlas and the
closing entanglement-radar and verification passes. Prep commits land
before feature commits, all on one PR.

The five correctness landmines are explicit task steps rather than
prose, because each one passes every test while being wrong:

- popHighestPriority pops the LARGEST priority, so the enqueue site
  negates; backwards, boot fetches in exactly reverse order.
- the drop trigger is a new idle && !demand edge, not release(), which
  cannot see a queued-but-unstarted slot.
- rank 6 needs distinct integers or ties fetch large-before-small.
- copyExternalImageToTexture's origin is in unflipped source
  coordinates while setMap uploads with flipY, so a wrong assumption
  mirrors the planet and samples the wrong tile row.
- load()'s promise must resolve on every terminal path or an abandoned
  load pins its queue slot.

Writing the plan surfaced two places the spec under-estimated. The
reevaluateDemand tests build a cast EngineState with no subsystems bag,
so the new queue lookup throws into the per-row catch and takes every
assertion down with it. And earthRenderer's setMap destroys the cell it
replaces, so P4b needs a real committed/placeholder split rather than
just accepting a bitmap, or a late atlas tile would destroy Blue Marble.
The frame's flat-vs-textured split inferred "this body has a texture" from
the bodyTextures slot's current() being non-null. That proxy holds only
while every bound texture has a slot behind it; a shared body-atlas tile
breaks it, and the naive fix (atlasReady || slotReady) would be a second
branch on one discriminant. Residency is a rendering fact, so hasMap(body,
kind) on TexturedBodyRenderer answers it from what is actually bound.

Behaviour is unchanged today: a committed slot is exactly a body with a
committed map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the five per-kind `let` cells and `setMap`'s five-branch retirement
ladder into two per-kind maps: `placeholders` (the 1x1s, seeded at construction
and freed only at teardown) and `committed` (the real map once uploaded).
`buildBindGroup` resolves each map binding as committed-over-placeholder, so a
map arriving out of order can no longer destroy the texture already on screen —
`setMap` retires only a prior COMMITTED texture of the same kind.

Binding numbers, the placeholder texels, and the layout entries now come from one
`KIND_CFG` table (mirroring texturedBodyRenderer), which also removes the
parallel five-branch texture-label ternary. Behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a REQUIRED `priority: number` to `AssetWiringRow` (lower is fetched
first, payload size folded in by the author) and sets the concrete integer
on every row in `ASSET_WIRING`. `pointRow` takes the rank as a parameter
because the eight galaxy catalogs do not share one; `bodyTextureRow` and
`starCatalogRow` each carry a constant.

The six bulk-survey ranks 60-65 are distinct on purpose: `popHighestPriority`
breaks ties by first-encountered, and the registry array's order would then
fetch GLADE before Milliquas.

The two stub-row test files gain `priority: 0` so the required field compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ommit

Makes the already-async work visible: load() now returns a Promise<void>
that resolves after commit completes or after any terminal early exit
(abort, give-up, superseded race-check), and never rejects. Every return
inside runLoad is a plain return in one async function, so returning it
satisfies every terminal path — this is the seam the boot-time load
queue needs to bound in-flight concurrency correctly.

forceReload() and the direct .load() call sites outside the queue's
scope (tier transition, companion assets, debug volume) mark the call
`void` now that it returns a promise they don't await.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boot load priority prep (P1): the queue's concurrency bound was a module-
global constant, so every consumer was forced to the same limit. The asset
queue wants 2 (a few big one-shot boot fetches shouldn't flood the
connection pool), while the thumbnail queue wants 4 (many small streaming
fetches as the camera moves). Make the limit a constructor argument
defaulting to MAX_CONCURRENT_FETCHES so galaxyAtlasSubsystem stays on the
current default while future callers can choose their own bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dyRenderer

The bind group resolved an uncommitted sphere map as
`res.maps.get(kind) ?? placeholderMaps.get(kind)!`. A per-BODY stand-in
(an atlas tile) would make that a three-term chain restated at every
rebuild site, with eviction having to know which term to fall back to.

Split the placeholder into two layers instead: the shared 1x1 per-kind
textures stay the default, and `BodyResources.placeholders` holds an
optional per-body override. `placeholderFor(bodyId, kind)` picks between
them, so the chain stays two-term and `clearMap` lands on whatever the
resolver gives at rebuild time. `hasMap` now reports the union of the two
layers: a body drawn from an override is textured. `destroy` frees the
override textures alongside the committed maps.

No override is seeded yet, so behaviour is unchanged; the seeding entry
point lands with the body-texture atlas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sceneBodyPartition now asks state.gpu.texturedBodyRenderer.hasMap(id,
'surface') for residency instead of the bodyTextures asset slot. Update the
fake texturedBodyRenderer/planetRenderer fixtures in texturedBodiesLayer and
planetsLayer's tests to express residency via hasMap, dropping the now-dead
assetSlots.bodyTextures seeding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds drop(key) and destroy() to PriorityQueue. drop() removes a PENDING
entry only, never touching inFlight — responses aren't resumable and the
spec's "never preempt" decision is explicit. destroy() clears every
pending entry and resolves outstanding drain() callers so teardown can't
hang, while letting in-flight tasks run out on their own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds ASSET_QUEUE_CONCURRENCY (N=2, distinct from the thumbnail queue's
MAX_CONCURRENT_FETCHES) and constructs state.subsystems.assetQueue eagerly
alongside scheduler/fades, so a future evaluateRows can enqueue boot fetches
before the GPU init IIFE finishes. Wires its destroy() into engine.destroy().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec test 1 for PriorityQueue ("runs at most the constructed limit
simultaneously") was lost in a git stash collision between two
concurrent agents working task P1 of the boot-load-priority plan; the
plan's checkbox was ticked despite the test never landing. Re-adds it
per the plan's P1 section: 6 tasks on new PriorityQueue(2), gated on
promises, asserting maxInFlight is exactly 2 (not <=, since a <=
assertion would also pass for a queue that serialises everything).

Also confirms the P1 repair item survived: the pre-existing
MAX_CONCURRENT_FETCHES test already constructs its queue explicitly
as new PriorityQueue(MAX_CONCURRENT_FETCHES).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The body atlas is a transport format: tiles are cropped out of the one
decoded bitmap into each body's existing texture at upload. That crop
needs a rect in unflipped source coordinates, and with the ring dropped
every cell is the same 512x256, so the rect derives from the tile index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One 2048x1024 sRGB WebP holding a 512x256 surface tile per registry body,
composited from the small tiers the same run writes, plus the generated
layout the runtime looks a tile index up in. Membership derives from
textureBuildEntries() filtered to `surface`, so the registry stays the one
enumeration of the textured set and the atlas cannot go stale behind a
re-curated body.

A body with no source on disk keeps its index and leaves a mid-grey cell,
so a --dev fetch never shifts every later body onto its neighbour's face.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`evaluateRows` no longer calls `slot.load()` directly. A demanded idle row is
enqueued onto `state.subsystems.assetQueue` with `priority: -row.priority` (the
queue pops the LARGEST priority, the wiring rank table reads lower-first), and
the loop grows a third edge: an idle row whose demand went false DROPS its
pending entry, which the `ready`-gated evict edge structurally cannot do.

Both the idle guard and `req(state.tier)` are re-evaluated inside the enqueued
closure, so a slot claimed by a direct `.load()` in the gap is left alone and a
tier change while an entry sits pending yields the request for the tier in
force when it runs.

Test repairs: six state fixtures across five files gain a per-call
`assetQueue`; the cases whose assertions count demanded rows rather than
concurrency now drain the queue first. `demandTable`'s `firedKeys` re-runs the
loop to a fixpoint, since `famousMeta` demands on the Famous row having started
and the queue defers that start past the pass that enqueued it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`setPlaceholderMap(bodyId, kind, atlas, rect)` crops one tile out of the
low-resolution all-bodies atlas into the body's per-(body, kind) placeholder
override layer, so a body reached before its hi-res map lands shows its own
surface rather than flat grey.

The atlas is a transport format, not a sampling format: the tile is cropped at
upload into an ordinary per-body texture, so nothing about the shader, the
bind-group layout, the sampler or the UVs changes, and the atlas bitmap is
never bound.

Writing the override layer (never `maps`) is what makes arrival order
irrelevant — a committed hi-res map shadows the tile by construction, with no
slot-state peek in any commit path.

The crop uses `copyExternalImageToTexture`'s source `origin`, which the spec
pins to unflipped top-left source coordinates while `flipY: true` flips the
selected region alone; the comment at the copy states that convention so the
next reader does not re-derive it. Still needs a visual pass (a wrong
assumption here is invisible to the mock device).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`setPlaceholderMap(kind, atlas, rect)` crops one tile out of the low-resolution
all-bodies atlas into that kind's placeholder slot, replacing the 1x1 mid-blue,
so an Earth reached before the multi-megabyte Blue Marble lands shows a
recognisable low-res Earth rather than a plain blue ball.

The tile lands in `placeholders`, never `committed`, and each setter frees only
its own layer: `setMap` retires a prior committed map, `setPlaceholderMap` a
prior placeholder. A tile arriving after the hi-res map therefore cannot clobber
it, and neither commit path has to ask which one landed first. Earth has no
`clearMap` and never evicts, so that out-of-order protection is the only thing
this site buys from the two-layer split.

Format comes from `isLinearTextureKind` exactly as `setMap` does, so the tile
and the map that later shadows it can never disagree on sRGB-vs-linear. Only
`'surface'` has an atlas tile; the other four kinds keep their 1x1s.

The crop follows `texturedBodyRenderer`'s convention (8c27868): the source
`origin`, which the spec pins to unflipped top-left coordinates while
`flipY: true` flips the selected region alone. Still needs a visual pass — the
mock device rasterises nothing, so a mirrored or wrong-row crop is green in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3 placeholders

Adds the `'bodyTextureAtlas'` asset: a singleton sidecar key alongside
`famousMeta` / `constellations` / `flow`, with a named `EngineAssetSlots` field,
its own fetcher and slot factory, and an `ASSET_WIRING` row at priority 0 — the
head of the rank table, because the whole point of the asset is to arrive before
any hi-res texture.

Everything the previous tasks landed was inert until this: the atlas is now
actually fetched (~160 KB, unconditionally at boot, deliberately NOT
proximity-gated) and its one decoded bitmap seeds every textured body's
placeholder layer, so a body reached before its own multi-MB map lands shows its
own surface instead of a flat albedo sphere.

The fan-out derives its membership from `BODY_ATLAS_LAYOUT` (generated from
`BODY_TEXTURE_REGISTRY`, the only enumeration of the textured-body set), and
routes Earth to `earthRenderer` and the other twelve to `texturedBodyRenderer` —
the same split `commitBodyTexture` already performs. Both sinks write the
PLACEHOLDER layer, never the committed one, so an atlas arriving after a hi-res
map cannot clobber it and no commit path has to ask which landed first. Renderers
cannot be late either: initGpu constructs both a phase before wireSlots mints
this slot, and `resourcesFor` mints a body's GPU resources lazily, so seeding a
body that has never been drawn just creates them early. The null-guards are the
usual destroy-race posture, not an ordering workaround.

Error posture is silent-optional-asset: a 404 or decode failure warns and leaves
every renderer on the 1x1 it drew before this feature existed.

No new test — the wiring is compile-checked end to end and the result is pixels.
The one test edit repairs the ASSET_WIRING membership pin. Still needs the visual
pass: a cold load should show every visible body textured from the first frame it
is drawable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two findings from the body-texture visual pass, neither caused by the
load-priority work:

- a thin fully-transparent ring between a body's surface and its
  atmosphere shell (seen on Mars). Filed as needs-investigation with a
  geometric hypothesis and a falsifiable first step, because a different
  cause points at a different fix.
- Mars reads over-saturated. Filed as the general question rather than a
  Mars fudge: the `sss` sources are enhanced rather than colorimetric and
  no target appearance is recorded anywhere, so a design has to pick a
  reference before "calibrated" means anything. Notes that any correction
  must also hit the atlas tiles, or a body's placeholder and its hi-res
  map will shift hue mid-load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`body-atlas.webp` was written twice: a string literal in `bodyAtlasFetcher`
and `BODY_ATLAS_FILENAME` in the emitter. A mismatch degrades as a silent
404 — the atlas never arrives, every body falls back to grey, and neither a
test nor the compiler can see it.

The emitter now copies its constant into `bodyAtlas.generated.ts` beside the
layout and the grid, the two other facts both ends must agree on, and the
fetcher builds its URL from that. Filename and bytes are written by the same
call, so they cannot drift — the guarantee `bodyTextureFilename` already
gives the per-body tiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The row-major index→pixel derivation was spelled twice: `atlasTileRect` for
the runtime crop, and an inline `left`/`top` computation in the emitter. A
tool that packed column-major while the runtime read row-major would hand
every body a neighbour's face, with the atlas otherwise valid and nothing to
catch it — the same failure class as the filename, in the same file.

The emitter now calls `atlasTileRect`, which it can: it already imports
`bodyTextureFilename` across the src/tools boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…witch

`isTextureResident` stopped meaning "the `bodyTextures` slot holds a committed
bitmap" when residency moved onto `texturedBodyRenderer.hasMap`; the
docblock still said it did, which is now the exact inference the switch
exists to forbid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The entanglement-radar sweep over the boot load-priority branch found
three knots that are real but wider than this feature:

- `ASSET_QUEUE_CONCURRENCY` is not the system-wide bound it reads as —
  five call sites still load outside the queue.
- "famousMeta rides Famous" is authored three times, in three
  mechanisms none of which implies the others.
- The `bodyId === 'earth'` commit routing is now branched in two
  places; appended to the body-texture store consolidation item, whose
  sink table would subsume it.

Ticks 4.1 in the part-2 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The asset-loading section rendered slots in engine-mint order with only
terminal state, so reading the boot fetch order off it meant knowing
ASSET_WIRING by heart — and a large asset that was dequeued first still
looked "late" because only its completion was visible.

Rows now sort by the authored ASSET_WIRING rank (lower fetches first) and
carry that rank in its own column, plus a `start→done` timing column in
seconds from the first fetch start. Start order and completion order are
therefore separately readable.

The rank map is derived from ASSET_WIRING via slotFor, not restated: a
static slot-name → rank table would be the same numbers in a second place.
Slots gain `startedAtMs()` — a load-attempt-scoped cell beside
`lastRequest()`, since `reduceLoadState` is clock-free by design.

AssetLoadingTitle and SlotRow move to their own files with their own CSS
modules, sharing the five LoadState colours through a composed
`loadStateColors.module.css`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
evaluateRows enqueued its loads row by row, and PriorityQueue.enqueue
starts a task the instant a slot is free. So the first N demanded rows
WALKED started immediately in ASSET_WIRING array order, before any
better-ranked row further down the table was evaluated; priority only
governed slots that freed later. That let rank-60 SDSS, second in the
array, hold one of the two boot pipes for 22 s ahead of everything the
Earth opening view actually draws.

Add PriorityQueue.enqueueMany: admit the whole batch into pending, then
tryStart once, so the first N started are the N best-ranked entries.
evaluateRows now collects demanded rows and submits them in one call.
The galaxy-thumbnail queue keeps eager per-frame enqueue untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
skymap e084081 Commit Preview URL

Branch Preview URL
Jul 24 2026, 08:31 PM

Part 1 (prep + bounded asset queue) is 39/39, shipped in PR #505.
Part 2 (body-texture atlas) stays in plans/ until its 9 manual
visual/perf attestations are checked against a cold local load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rulkens
rulkens merged commit 16d039c into main Jul 24, 2026
1 of 2 checks passed
@rulkens
rulkens deleted the worktree-boot-load-priority branch July 24, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant