Build, validate, install, scan, and serve schema-plus-packed-binary Atlas volumes as the sole production format. Unify picker, native file-open, and drag-and-drop intake, regenerate the bundled Earth volume, and document the cutover.
- commit
- 224e5d2d9c8f7a9cf23a2ed852d33af997ca8eec
- parent
- 56d23aa3e4a1018aab2a4fc6b3739fb517adf222
- author
- Dami <dami.etoile@gmail.com>
@@ -204,6 +204,18 @@ jobs:
204204 <key>CFBundlePackageType</key><string>APPL</string>205205 <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>206206 <key>CFBundleShortVersionString</key><string>${VERSION#v}</string>207+ <key>CFBundleDocumentTypes</key><array><dict>208+ <key>CFBundleTypeName</key><string>Atlas Volume</string>209+ <key>CFBundleTypeRole</key><string>Viewer</string>210+ <key>LSItemContentTypes</key><array><string>dev.felinestatemachine.atlas.volume</string></array>211+ </dict></array>212+ <key>UTExportedTypeDeclarations</key><array><dict>213+ <key>UTTypeIdentifier</key><string>dev.felinestatemachine.atlas.volume</string>214+ <key>UTTypeConformsTo</key><array><string>public.data</string><string>public.archive</string></array>215+ <key>UTTypeDescription</key><string>Atlas Volume</string>216+ <key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key><array><string>atlas</string></array></dict>217+ </dict></array>218+ <key>LSSupportsOpeningDocumentsInPlace</key><true/>207219 <key>LSMinimumSystemVersion</key><string>12.0</string>208220 <key>NSHighResolutionCapable</key><true/>209221 </dict>@@ -124,6 +124,18 @@ desktop: static
124124 ' <key>CFBundleName</key><string>Atlas</string>' \125125 ' <key>CFBundlePackageType</key><string>APPL</string>' \126126 ' <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>' \127+ ' <key>CFBundleDocumentTypes</key><array><dict>' \128+ ' <key>CFBundleTypeName</key><string>Atlas Volume</string>' \129+ ' <key>CFBundleTypeRole</key><string>Viewer</string>' \130+ ' <key>LSItemContentTypes</key><array><string>dev.felinestatemachine.atlas.volume</string></array>' \131+ ' </dict></array>' \132+ ' <key>UTExportedTypeDeclarations</key><array><dict>' \133+ ' <key>UTTypeIdentifier</key><string>dev.felinestatemachine.atlas.volume</string>' \134+ ' <key>UTTypeConformsTo</key><array><string>public.data</string><string>public.archive</string></array>' \135+ ' <key>UTTypeDescription</key><string>Atlas Volume</string>' \136+ ' <key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key><array><string>atlas</string></array></dict>' \137+ ' </dict></array>' \138+ ' <key>LSSupportsOpeningDocumentsInPlace</key><true/>' \127139 ' <key>LSMinimumSystemVersion</key><string>12.0</string>' \128140 ' <key>NSHighResolutionCapable</key><true/>' \129141 '</dict>' \@@ -14,7 +14,7 @@ import (
1414 "strings"1515 "testing"1616 17- "github.com/FelineStateMachine/atlas/format/bundle"17+ "github.com/FelineStateMachine/atlas/format/vnext"1818 "github.com/FelineStateMachine/atlas/internal/enrich"1919 "github.com/FelineStateMachine/atlas/internal/generate/archive"2020 "github.com/FelineStateMachine/atlas/internal/generate/compose"@@ -456,9 +456,9 @@ func verifyBundles(t *testing.T, registry string) {
456456 if len(paths) != 2 {457457 t.Fatalf("the registry holds %d bundles, want the city and the merged game", len(paths))458458 }459- byVolume := map[string]*bundle.Reader{}459+ byVolume := map[string]*vnext.File{}460460 for _, path := range paths {461- reader, err := bundle.Open(path)461+ reader, err := vnext.OpenFile(path, vnext.StandardSchema())462462 if err != nil {463463 t.Fatalf("open %s: %v", filepath.Base(path), err)464464 }@@ -466,7 +466,7 @@ func verifyBundles(t *testing.T, registry string) {
466466 if err := reader.Validate(); err != nil {467467 t.Fatalf("validate %s: %v", filepath.Base(path), err)468468 }469- byVolume[reader.Manifest.Volume.Slug] = reader469+ byVolume[reader.VolumeID] = reader470470 }471471 472472 t.Run("the merged game", func(t *testing.T) {@@ -476,55 +476,30 @@ func verifyBundles(t *testing.T, registry string) {
476476 }477477 // The version is the capture, not the build clock, and an enriched478478 // build outranks the plain build of the same capture by revision.479- if got := reader.Manifest.Version.CreatedAt; got != valeServingCapturedAt {479+ if got := reader.Release.CreatedAt; got != valeServingCapturedAt {480480 t.Errorf("createdAt %q, want the serving capture time %q", got, valeServingCapturedAt)481481 }482482 enriched, err := enrich.BuildRevision(compose.PolicyRevision)483483 if err != nil {484484 t.Fatal(err)485485 }486- if reader.Manifest.Version.Revision != enriched {487- t.Errorf("revision %d, want the enriched %d", reader.Manifest.Version.Revision, enriched)486+ if reader.Release.Revision != enriched {487+ t.Errorf("revision %d, want the enriched %d", reader.Release.Revision, enriched)488488 }489-490- // The ledger records the merge: the origin account and the donor's,491- // every anchor corroborated at no distance, and the donor's three own492- // places added.493- var payload struct {494- Merged []enrich.Account `json:"merged"`495- Collections []struct {496- Title string `json:"title"`497- Group string `json:"group"`498- Kind string `json:"kind"`499- } `json:"collections"`500- }501- readBundleJSON(t, reader, bundle.WorldEntryName("world", bundle.WorldSuffix), &payload)502- if len(payload.Merged) != 2 {503- t.Fatalf("the ledger carries %d accounts, want origin and donor", len(payload.Merged))504- }505- origin, donor := payload.Merged[0], payload.Merged[1]506- if !origin.Origin || origin.Slug != "mapgenie" {507- t.Errorf("the first account is %s/origin=%t, want the serving reading's own", origin.Slug, origin.Origin)508- }509- if donor.Origin || donor.Slug != "ign-wiki" {510- t.Errorf("the second account is %s/origin=%t, want the folded donor", donor.Slug, donor.Origin)511- }512- if donor.MatchedN() != len(valeAnchors) || donor.MedianMatchPx() != 0 {513- t.Errorf("the donor matched %d places at median %dpx, want all %d anchors at 0",514- donor.MatchedN(), donor.MedianMatchPx(), len(valeAnchors))515- }516- if donor.Added != len(donorOnly) || donor.HeldN() != 0 || donor.RejectedN() != 0 {517- t.Errorf("the donor added %d, held %d, rejected %d; it offered exactly %d places of its own",518- donor.Added, donor.HeldN(), donor.RejectedN(), len(donorOnly))519- }520- if donor.Alignment == "" {521- t.Error("the donor's account names no alignment, and the merge stood on one")489+ semantic, err := reader.Volume()490+ if err != nil {491+ t.Fatal(err)522492 }523- // The donor-only collection travelled, filed under its source's name.493+ // The donor-only semantic feature set travelled, filed under its source.524494 var extras bool525- for _, collection := range payload.Collections {526- if collection.Title == "Extras" && collection.Group == "IGN Wiki" {527- extras = true495+ world := semantic.Worlds[0]496+ sets := make(map[string]vnext.FeatureSet)497+ for _, set := range world.FeatureSets {498+ sets[set.ID] = set499+ }500+ for _, layer := range world.Presentation.Layers {501+ if sets[layer.FeatureSet].Title == "Extras" && layer.Group == "IGN Wiki" {502+ extras = len(sets[layer.FeatureSet].Features) == len(donorOnly)528503 }529504 }530505 if !extras {@@ -537,19 +512,25 @@ func verifyBundles(t *testing.T, registry string) {
537512 if reader == nil {538513 t.Fatal("no build of the city was installed")539514 }540- if got := reader.Manifest.Version.CreatedAt; got != cityCapturedAt {515+ if got := reader.Release.CreatedAt; got != cityCapturedAt {541516 t.Errorf("createdAt %q, want the capture time %q", got, cityCapturedAt)542517 }543- if reader.Manifest.Version.Revision != compose.PolicyRevision {544- t.Errorf("revision %d, want the plain %d", reader.Manifest.Version.Revision, compose.PolicyRevision)518+ if reader.Release.Revision != compose.PolicyRevision {519+ t.Errorf("revision %d, want the plain %d", reader.Release.Revision, compose.PolicyRevision)545520 }546521 // The standard glyph the pins declare was resolved from the vendored547522 // library into the bundle's own artwork.548523 var monument, drawn bool549- for _, name := range reader.Names() {550- if name == "icons/std--maki-monument.svg" {524+ semantic, err := reader.Volume()525+ if err != nil {526+ t.Fatal(err)527+ }528+ for _, asset := range semantic.Assets {529+ if asset.ID == "std--maki-monument.svg" {551530 monument = true552531 }532+ }533+ for _, name := range reader.BlobNames() {553534 if strings.HasPrefix(name, "tiles/"+cityDay+"/") && strings.HasSuffix(name, ".png") {554535 drawn = true555536 }@@ -562,34 +543,25 @@ func verifyBundles(t *testing.T, registry string) {
562543 }563544 // All three feature kinds crossed the pipeline: pins, the multipart564545 // zoning ground, and the trail path.565- var payload struct {566- Collections []struct {567- Title string `json:"title"`568- Kind string `json:"kind"`569- Features []struct {570- Title string `json:"title"`571- Geometry []json.RawMessage `json:"geometry"`572- } `json:"features"`573- } `json:"collections"`574- }575- readBundleJSON(t, reader, bundle.WorldEntryName(cityDay, bundle.WorldSuffix), &payload)576- for _, collection := range payload.Collections {577- if collection.Title == "Zoning" {578- for _, zone := range collection.Features {579- if zone.Title == "RS" && len(zone.Geometry) != 1 {580- t.Errorf("the RS zone draws %d parts; its one captured row is the multipart one",581- len(zone.Geometry))582- }546+ points, areas, paths := 0, 0, 0547+ for _, set := range semantic.Worlds[0].FeatureSets {548+ for _, feature := range set.Features {549+ switch feature.Geometry.Kind {550+ case vnext.GeometryPoint:551+ points++552+ case vnext.GeometryPolygon:553+ areas++554+ case vnext.GeometryLineString:555+ paths++556+ }557+ if set.Title == "Zoning" && feature.Title == "RS" && len(feature.Geometry.Parts) != 2 {558+ t.Errorf("the RS zone draws %d parts; want its two polygon parts", len(feature.Geometry.Parts))583559 }584560 }585561 }586- // Point features travel packed rather than inline in the payload, so587- // the kinds are read off the manifest's own tally: the pins, the two588- // zoning grounds and the trail path all crossed the pipeline.589- entry := reader.Manifest.Worlds[0]590- if entry.Points != 2 || entry.Areas != 2 || entry.Paths != 1 {562+ if points != 2 || areas != 2 || paths != 1 {591563 t.Errorf("the world holds %d pins, %d areas and %d paths, want 2, 2 and 1",592- entry.Points, entry.Areas, entry.Paths)564+ points, areas, paths)593565 }594566 })595567 }@@ -610,17 +582,6 @@ func writeJSONFile(t *testing.T, path string, value any) {
610582 }611583 }612584 613-func readBundleJSON(t *testing.T, reader *bundle.Reader, entry string, dst any) {614- t.Helper()615- data, err := reader.ReadEntry(entry)616- if err != nil {617- t.Fatalf("read %s: %v", entry, err)618- }619- if err := json.Unmarshal(data, dst); err != nil {620- t.Fatalf("decode %s: %v", entry, err)621- }622-}623-624585 // tileBound spells one zoom's inclusive window the way a MapGenie capture does.625586 func tileBound(maxX, maxY int) map[string]any {626587 return map[string]any{@@ -17,7 +17,8 @@ pictures it, and [`decisions/`](decisions/) is why any of it is shaped this way.
1717 1818 | Document | What it is |1919 |---|---|20-| [`format.md`](format.md) | Normative `.atlas` v3 spec, sufficient alone to implement a reader: the container, the manifest, the world payload, the `ATLASLOC` bytes, stamps and ordering, validation, the registry directory, and a checklist for a new reader. |20+| [`format.md`](format.md) | Normative native schema-bundle spec: semantic graph, typed blocks, compatibility, release ordering, validation, and install. |21+| [`format-v3.md`](format-v3.md) | Historical specification for the retired document-shaped format; current readers do not install it. |2122 | [`semconv/REGISTRY.md`](semconv/REGISTRY.md) | The attribute vocabulary, generated from `spec/registry.yaml`; `spec/gen`'s own test holds the committed copy up to date. |2223 | [`generate.md`](generate.md) | The generate lane: the interchange document, the sources and their gates, the capture archive, tile derivation, composition, curation. §2.8 is the "add a source" walkthrough. |2324 | [`enrich.md`](enrich.md) | The enrich lane: the enricher interface, the contribution format, the ledger vocabulary, the curated queue, the maturity score. |@@ -0,0 +1,704 @@
1+# The `.atlas` format — version 32+3+**Status: normative.** This document specifies the `.atlas` container as it is4+frozen at format version 3, semantic conventions version 2. It is written to5+be sufficient on its own: an implementer who has never seen Atlas the6+application should be able to write a conforming reader from this document and7+nothing else. Where this document and any implementation disagree, take it as8+a defect in one of them and say so.9+10+The reference implementation is `format/bundle` and `format/semconv`, which11+depend on the Go standard library alone. The attribute vocabulary has its own12+normative document at [`semconv/REGISTRY.md`](semconv/REGISTRY.md).13+14+---15+16+## 1. Vocabulary17+18+Five words carry the format. They are used precisely throughout.19+20+| Word | Meaning |21+|---|---|22+| **Volume** | The subject of one `.atlas` file: a game, a planet, a city. One file, one volume. |23+| **World** | A distinct ground within a volume that a reader can stand on. For a game these are its maps; for a captured city, its dated revisions. |24+| **Lens** | One raster tile pyramid picturing a world. Worlds are grounds; lenses are pictures of them. A world may have several lenses — map styles, dated captures, warped variants. |25+| **Collection** | An ordered group of features within a world. Every collection declares one **kind**: `point`, `path`, or `area`. |26+| **Feature** | One point, path, or area, with a title and attributes. Every feature is exactly one kind. |27+28+A **build** is one packing of a volume's data. Builds of one volume share a29+slug and are told apart by their **stamp**. Nothing else in the format is a30+noun.31+32+The format carries no trace of where a volume's data came from. A producer33+flattens whatever its sources look like into this shape; a reader only ever34+sees this shape. The one exception is the optional merge ledger (§6.6), which35+is provenance a reader may show and must never depend on.36+37+---38+39+## 2. The three invariants40+41+These hold for every conforming producer and reader. Everything else in this42+document serves them.43+44+### 2.1 Determinism45+46+The same input archive, built anywhere, at any time, on any machine, produces47+the same stamp and therefore the same file name.48+49+Concretely: the stamp is an order-independent SHA-256 over named part hashes50+(§8.1); the file name is `<slug>-<YYYYMMDD>-<stamp12>.atlas` (§8.2);51+`createdAt` is the **newest capture time** across the volume's worlds, never52+the build time; and the build ordering is `createdAt`, then policy `revision`,53+then `stamp` (§8.3).54+55+### 2.2 Offline purity56+57+**No runtime URL appears anywhere in a payload.** A bundle serves with zero58+network access, forever. A producer must reject a build whose59+`worlds/<slug>.json` or `worlds/<slug>.text` contains the substring `http://`60+or `https://` (§9.2). Cross-references that a source published as URLs are61+resolved at build time to in-volume identifiers, or dropped.62+63+### 2.3 Asymmetric conventions64+65+Producers are strict; readers are lenient — with exactly one exception.66+67+- **Producers**: an `atlas.*` attribute the registry does not know, or one68+ attached to the wrong entity, or one carrying a value outside its69+ vocabulary, **fails the build**.70+- **Readers**: unknown attribute keys are **ignored, never refused**. A bundle71+ written by a newer pipeline still opens in an older reader.72+- **The exception**: an unknown `formatVersion` is **refused outright**. A73+ reader that guesses at a layout it does not know is worse than one that says74+ so.75+76+---77+78+## 3. The container79+80+A `.atlas` file is a **zip archive**. It holds:81+82+```83+atlas.json the manifest — deflated84+worlds/<world-slug>.json world payload — deflated85+worlds/<world-slug>.bin packed point locations — STORED86+worlds/<world-slug>.text per-feature prose — deflated87+tiles/<pyramid>/<z>/<x>/<y>.<ext> raster tiles — STORED88+icons/<name> icon assets — deflated89+```90+91+### 3.1 Compression policy92+93+**Tiles and packed locations are stored uncompressed.** They are already dense94+— JPEG, PNG, WebP, packed binary — and a stored zip entry can be served as a95+straight byte range out of the archive, where a deflated one must be inflated96+on every read. This is the one performance property the container is designed97+around, and a conforming producer must honour it.98+99+JSON payloads and icons are deflated: text shrinks severalfold and is read100+whole.101+102+### 3.2 Entry ordering103+104+`atlas.json` is written first, so a listing of the archive leads with what the105+file is. A reader must find entries by name and must not depend on order for106+anything else.107+108+### 3.3 Entry names109+110+Entry names are slash-separated relative paths of plain segments. A name must111+not be empty, must not begin or end with `/`, and no segment may be empty,112+`.`, or `..`. A reader that unpacks a bundle to disk is entitled to assume113+this held, and a producer must enforce it.114+115+### 3.4 Slugs116+117+Volume and world slugs are identifiers used unescaped as both path segments118+and URL segments. A slug matches `[a-z0-9_-]+` and must not begin with `-` or119+`_`. Nothing else is admitted: everything a hostile name could smuggle needs120+more.121+122+---123+124+## 4. `atlas.json` — the manifest125+126+The manifest is what a bundle says about itself. It is read through to list a127+volume without touching any payload.128+129+**A conforming reader must refuse an archive with no `atlas.json`.** The130+reference implementation also refuses one whose uncompressed size exceeds 1131+MiB, on the grounds that a manifest lists worlds and nothing per feature.132+133+### 4.1 Schema134+135+```json136+{137+ "format": "atlas-bundle",138+ "formatVersion": 3,139+ "conventions": 2,140+ "volume": { "slug": "bend-or", "title": "Bend, Oregon" },141+ "version": {142+ "stamp": "ec3fe8c21cfec928d43a616eca97ebda08dc0ad7a970f531c677241bb5c70788",143+ "createdAt": "2026-08-01T20:13:08.883648Z",144+ "revision": 9145+ },146+ "tileGrid": { "sourceZoom": 13, "firstTile": 4064, "tileSize": 256, "size": 8192 },147+ "worlds": [148+ {149+ "slug": "2026-08-01",150+ "title": "2026-08-01",151+ "center": { "lat": 0, "lng": 0 },152+ "points": 45, "paths": 5, "areas": 16,153+ "updatedAt": "2026-08-01T20:13:08.883648Z"154+ }155+ ]156+}157+```158+159+| Field | Type | Required | Meaning |160+|---|---|---|---|161+| `format` | string | yes | Always `"atlas-bundle"`. A reader refuses any other value, so a stray zip renamed to `.atlas` fails by name rather than by whatever breaks first. |162+| `formatVersion` | integer | yes | `3`. A reader refuses versions it does not know (§2.3). |163+| `conventions` | integer | omitted when 0 | The semantic-conventions vocabulary the payloads were written against. `2` is current; absent means a bundle from before the conventions existed. A **declaration**, never a gate: a reader that knows a different vocabulary still opens the bundle. Validation is stricter about a bundle that declares (§9.4). |164+| `volume.slug` | slug | yes | The volume's identity. Two bundles naming the same slug are two builds of the same volume, however their files are named. |165+| `volume.title` | string | yes, non-empty | Display name. |166+| `version.stamp` | hex string | yes, non-empty | Content fingerprint (§8.1). Two bundles with equal stamps hold the same data. |167+| `version.createdAt` | RFC 3339 | yes, non-empty | The newest capture time across the volume's worlds. **Never the build time.** |168+| `version.revision` | integer | omitted when 0 | Policy revision: which generation of build rules produced this file. Orders builds of one capture (§8.3). |169+| `tileGrid` | object | yes | The window this volume's worlds are cut from (§4.2). |170+| `worlds` | array | yes, non-empty | One entry per world (§4.3). Order is the offering order. |171+172+### 4.2 `tileGrid`173+174+| Field | Type | Meaning |175+|---|---|---|176+| `sourceZoom` | integer | The zoom level the source raster was captured at. |177+| `firstTile` | integer | The tile column/row the captured window begins at, in the source grid. |178+| `tileSize` | integer | Pixels per tile edge. `256` throughout. |179+| `size` | integer | The world square's edge in world pixels. `8192` throughout. |180+181+A world cut from a window of its own overrides `sourceZoom` and `firstTile` in182+its own payload (§6.2).183+184+### 4.3 `worlds[]`185+186+| Field | Type | Required | Meaning |187+|---|---|---|---|188+| `slug` | slug | yes | Names the world's three payload entries and is the world's identity everywhere. Must be unique within the manifest. |189+| `title` | string | yes, non-empty | Display name. |190+| `parent` | slug | omitted when empty | The world this one was split out of, so an inset sorts with the sheet it came from. |191+| `iconOutset` | string | omitted when empty | `light` or `dark`: the rim markers wear to stay legible against this world's art. Duplicated as `atlas.icon.outset` in the payload. |192+| `center` | `{lat, lng}` | yes | Opening view, in the volume's own projection. |193+| `points`, `paths`, `areas` | integer | yes | Feature counts by kind. Every feature is exactly one of the three, so a listing can say how much a world holds without opening it — and validation holds the payload to the promise (§9.3). |194+| `updatedAt` | RFC 3339 | yes | This world's capture time. The manifest's `createdAt` is the newest of these. |195+196+### 4.4 Manifest encoding is normative197+198+The manifest's **encoded bytes** feed the stamp (§8.1), so its encoding is part199+of the format, not an implementation detail:200+201+- Field order is exactly as tabled above.202+- `conventions` and `version.revision` are omitted when zero. **Every other203+ field is always present**, including zero-valued ones (`"points":0`,204+ `"center":{"lat":0,"lng":0}`, `"updatedAt":""`).205+- Compact — no indentation, no trailing newline.206+- HTML-sensitive characters in strings are escaped to their `\u` forms:207+ `<` becomes `\u003c`, `>` becomes `\u003e`, `&` becomes `\u0026`. This is208+ Go's `encoding/json` default and it is load-bearing — a volume titled209+ `Hospitals & Care` is encoded with `\u0026` in every published bundle, and210+ an encoder that emits a bare `&` computes a different stamp.211+212+An implementation that re-encodes a manifest and gets different bytes will213+compute different stamps and produce differently named files.214+215+---216+217+## 5. World payload entries218+219+Each world splits three ways, by when its parts are needed:220+221+| Entry | Read when | Holds |222+|---|---|---|223+| `worlds/<slug>.json` | the world opens | lenses, collections, inline shape geometry, world attributes, merge ledger |224+| `worlds/<slug>.bin` | the world opens | every point feature, packed |225+| `worlds/<slug>.text` | a feature's card opens | descriptions, links, per-feature attributes |226+227+The split exists because descriptions were half the payload by weight and are228+read one feature at a time, while point features are overwhelmingly numbers,229+and numbers written as text cost several times what they measure. Shape230+features are mostly geometry either way, so they ride the JSON inline.231+232+---233+234+## 6. `worlds/<slug>.json` — the world payload235+236+```json237+{238+ "grid": { "sourceZoom": 5, "firstTile": 0 },239+ "lenses": [ … ],240+ "collections": [ … ],241+ "attrs": { "atlas.geometry.surface": "sphere", … },242+ "merged": [ … ]243+}244+```245+246+| Field | Required | Meaning |247+|---|---|---|248+| `grid` | optional | Window override (§6.2). Present only on a world cut from a window that is not the manifest's. |249+| `lenses` | yes, non-empty | The pictures of this world (§6.3). |250+| `collections` | yes | **Order-significant** (§6.4). May be empty. |251+| `attrs` | optional | The world speaking the shared conventions, entity `world`. |252+| `merged` | optional | Provenance ledger (§6.6). A reader may show it and must never depend on it. |253+254+### 6.1 Attributes generally255+256+`attrs` objects appear on the world, on each collection, and on each feature.257+They are flat `string → string` maps. Keys in the `atlas.` namespace are258+governed by [`semconv/REGISTRY.md`](semconv/REGISTRY.md); keys outside it are259+not the registry's business and pass through. **A reader ignores any key it260+does not know** (§2.3).261+262+### 6.2 `grid`263+264+```json265+{ "sourceZoom": 5, "firstTile": 0 }266+```267+268+Overrides the manifest's `tileGrid.sourceZoom` and `tileGrid.firstTile` for269+this world. `tileSize` and `size` are never overridden.270+271+### 6.3 `lenses[]`272+273+One raster pyramid picturing this world.274+275+| Field | Type | Required | Meaning |276+|---|---|---|---|277+| `name` | string | yes | Display name of the picture, e.g. `"Viking MDIM 2.1"`, `"Basemap"`. |278+| `tiles` | string | yes | The pyramid's directory under `tiles/`. Several lenses may name one pyramid. |279+| `minZoom` | integer | yes | Lowest zoom the pyramid holds. **`0` in every published bundle.** |280+| `maxZoom` | integer | yes | Highest zoom the pyramid holds. |281+| `fullZoom` | integer | yes | The highest zoom at which the pyramid is *complete*. Above it, tiles exist only where the capture reached, and `coverage` says where. |282+| `sourceZoom` | integer | yes | The zoom this pyramid's source raster was captured at. |283+| `formats` | array of string | yes | One file extension per level, indexed by `z − minZoom`: `formats[z − minZoom]` is the extension of every tile at zoom `z`. Length must equal `maxZoom − minZoom + 1`. |284+| `bounds` | `{x,y,width,height}` | optional | The window of world pixels the pyramid's tiles cover. |285+| `surface` | `{x,y,width,height}` | optional | The ground the world actually covers, where `bounds` is the window cut to draw it. On a piece of a split sheet the window is grown to take in a title drawn beside it, so anything dividing the world up measures `surface` and leaves no cell on margin. |286+| `interpolate` | boolean | yes | `true` to resample smoothly (photographic rasters); `false` for nearest-neighbour (pixel art). |287+| `background` | string | optional | CSS colour behind the raster, e.g. `"#14181d"`. |288+| `shard` | integer | omitted when 0 | Which layer of a split world this lens draws. Features carry a matching `shard`. |289+| `coverage` | object | optional | Sparse-level bitsets (§6.3.1). Absent for a level means that level is fully covered. |290+291+#### 6.3.1 `coverage`292+293+```json294+"coverage": { "3": { "x": 1, "y": 0, "w": 7, "h": 8, "bits": "YDDc//v5cA==" } }295+```296+297+Keyed by **zoom level as a decimal string**. Each value describes which tiles298+of that level were actually written:299+300+- `x`, `y`, `w`, `h` — the bounding tile window at that level.301+- `bits` — **standard base64** of a row-major bitset over that window, **least302+ significant bit first within each byte**.303+304+To test whether tile `(z, x, y)` exists:305+306+```307+c = x − coverage.x; r = y − coverage.y308+if c < 0 or r < 0 or c >= coverage.w or r >= coverage.h: absent309+i = r * coverage.w + c310+present ⟺ (bits[i >> 3] & (1 << (i & 7))) != 0311+```312+313+A level with no `coverage` entry is fully covered. A reader must consult the314+bitset before requesting a tile: asking for one that was never written is a315+wasted request, and the correct fallback is to draw the parent tile.316+317+### 6.4 `collections[]` — order is significant318+319+The collections array is the world's whole structure in one flat list.320+**Its order is load-bearing**: the packed payload's `owner` column indexes it321+(§7).322+323+Point collections are written first and shape collections after, so a packed324+location's `owner` is simply the ordinal of its collection among the points.325+The array order is also the legend order.326+327+| Field | Type | Required | Meaning |328+|---|---|---|---|329+| `id` | integer | yes | Stable identifier, unique within the world. |330+| `title` | string | yes | Display name. |331+| `group` | string | optional | A heading several collections sit under. |332+| `kind` | string | yes | `point`, `path`, or `area`. |333+| `icon` | string | optional | The collection's icon key, before resolution. |334+| `iconAsset` | string | optional | The resolved icon's name under `icons/`. Must exist in the archive. |335+| `iconPicture` | boolean | omitted when false | The asset is a picture drawn as-is rather than a glyph the reader tints. |336+| `color`, `iconColor` | string | optional | CSS colours. |337+| `visible` | boolean | yes | Whether the collection is drawn when the world opens. |338+| `attrs` | object | optional | Conventions, entity `collection`. |339+| `features` | array | optional | Inline features. **A point collection carries none** — its members ride the packed payload. Path and area collections carry theirs here. |340+341+### 6.5 `features[]` — inline shape features342+343+Present only on `path` and `area` collections.344+345+| Field | Type | Required | Meaning |346+|---|---|---|---|347+| `id` | integer | yes | Unique within the world, across *all* kinds — one world never keys two things alike. |348+| `title` | string | yes | Display name. |349+| `subtitle` | string | optional | Secondary line. |350+| `hasText` | boolean | omitted when false | This feature has an entry in `worlds/<slug>.text`. |351+| `parent` | integer | optional | The id of a containing feature. |352+| `center` | `{lat,lng}` | optional | A representative point, for framing and labelling. |353+| `shard` | integer | omitted when 0 | Which layer of a split world this feature belongs to. |354+| `geometry` | array | yes | GeoJSON-shaped geometry objects: `{"type": …, "coordinates": …}`. |355+| `attrs` | object | optional | Conventions, entity `feature`. |356+357+Geometry types must match the collection's kind:358+359+| Kind | Admitted geometry types |360+|---|---|361+| `area` | `Polygon`, `MultiPolygon` |362+| `path` | `LineString`, `MultiLineString` |363+| `point` | *none* — points are never inlined |364+365+Coordinates are in the volume's own world space (the same `lat`/`lng`366+convention as `center`), not WGS 84. A world that declares a spherical surface367+also declares the mapping from world space to true coordinates (§6.7).368+369+### 6.6 `merged[]` — the provenance ledger370+371+Optional. Present when a build folded more than one source together, or to372+record where a single-source world came from.373+374+```json375+[{ "source": "ArcGIS Open Data", "slug": "arcgis-hub", "origin": true,376+ "donorFeatures": {"point": 45, "path": 5, "area": 16}, "added": 0 }]377+```378+379+| Field | Meaning |380+|---|---|381+| `source` | Display name of the contributing source. |382+| `slug` | The source's canonical name, so ledgers and tooling agree without translation. |383+| `origin` | This entry accounts for the source the world itself came from. Its `donorFeatures` is the world's own tally at composition. |384+| `donorFeatures` | `{point, path, area}` — the donor's whole offering, counted per kind. |385+| `matched`, `added`, `addedShapes`, `adopted`, `held`, `rejected`, `enrichedCategories`, `alignment` | The merge's own account: what met an existing feature, what was new, what was held back and why. Producer-defined; a reader displays what it recognises. |386+387+**A reader must never depend on the ledger.** It is provenance a curious388+person reads, not data the picture needs.389+390+### 6.7 Spherical worlds391+392+A world whose raster pictures a sphere declares so in `attrs`:393+394+```json395+"attrs": {396+ "atlas.geometry.surface": "sphere",397+ "atlas.geometry.projection": "equirect",398+ "atlas.geometry.equirect.px": "0,0,8192,4096",399+ "atlas.geometry.equirect.deg": "-180,90,180,-90",400+ "atlas.geometry.body": "mars",401+ "atlas.geometry.radius_km": "3389.5"402+}403+```404+405+`equirect.px` is `x,y,w,h` in world pixels; `equirect.deg` is406+`west,north,east,south` in degrees. The mapping is linear in both axes:407+408+```409+x = X + (lon − West) / (East − West) * W410+y = Y + (North − lat) / (North − South) * H411+412+lon = West + (x − X) / W * (East − West)413+lat = North − (y − Y) / H * (North − South)414+```415+416+West may exceed east numerically (a world centred on the antimeridian); the417+span still reads west to east. `w` and `h` must be positive, and north must418+differ from south and west from east.419+420+A world that says nothing about its surface is a **plane**. A world declaring421+`sphere` **must** also declare `projection` and both `equirect.*` keys (§9.4).422+423+The `atlas.geometry.mercator.*` keys declare a Web-Mercator cut instead: `y`424+is linear in the projected latitude, `asinh(tan lat)`, rather than in degrees.425+They are experimental.426+427+---428+429+## 7. `worlds/<slug>.bin` — `ATLASLOC` version 3430+431+Every point feature of a world, packed as parallel little-endian typed arrays.432+The layout exists so a reader can build typed-array views directly over the433+downloaded buffer, with no parsing and no per-feature allocation.434+435+**All integers and floats are little-endian.** Let `N` be the location count.436+437+| Offset | Size | Type | Column |438+|---|---|---|---|439+| `0` | 8 | bytes | Magic: ASCII `ATLASLOC` |440+| `8` | 2 | `uint16` | Version. `3`. |441+| `10` | 4 | `uint32` | `N`, the location count |442+| `14` | 2 | — | Reserved, zero. Present so every column that follows is four-byte aligned. |443+| `16` | `4N` | `int32` | **id** |444+| `16 + 4N` | `4N` | `float32` | **lat** |445+| `16 + 8N` | `4N` | `float32` | **lng** |446+| `16 + 12N` | `4N` | `int32` | **member** |447+| `16 + 16N` | `4N` | `int32` | **shard** |448+| `16 + 20N` | `4(N+1)` | `uint32` | **title offsets** |449+| `20 + 24N` | `2N` | `uint16` | **owner** |450+| `20 + 26N` | variable | bytes | **title bytes** |451+452+Total size is `20 + 26N + Σ len(title_i)`.453+454+### 7.1 Columns455+456+| Column | Meaning |457+|---|---|458+| **id** | The feature's identifier, unique within the world across all kinds. Signed 32-bit on the wire. |459+| **lat**, **lng** | Position in the volume's world space. Single precision, which resolves far finer than a world is drawn. A conforming reader must expect coordinates to be float32-rounded. |460+| **member** | The id of the **area feature containing this point**, or `0` for none. |461+| **shard** | Which layer of a split world the point belongs to, or `0` when the world is whole. |462+| **owner** | **The index of this feature's collection in the world payload's `collections` array.** This is the only collection identity a reader needs, and it is why that array's order is significant. `owner` must index an existing collection whose `kind` is `point`. |463+| **title** | See below. |464+465+### 7.2 Titles466+467+The title offsets column holds `N+1` byte offsets into the title region,468+relative to its start. Location `i`'s title is469+`titles[offsets[i] : offsets[i+1]]`, as **UTF-8, not NUL-terminated**. Offsets470+are non-decreasing; `offsets[0]` is `0` and `offsets[N]` is the length of the471+title region. An empty title is a zero-length run, which is legal.472+473+A conforming reader should verify that the offsets are non-decreasing and that474+`offsets[N]` does not exceed the remaining bytes, because these are the only475+values in the payload that can point outside it.476+477+### 7.3 Version history478+479+- **1** — the original packing.480+- **2** — added the `shard` column. A world split into layers offers one at a481+ time; without it every layer's locations drew over every other.482+- **3** — moved meaning without moving a byte. `owner` now indexes the world483+ payload's `collections` array rather than a flattened category order, and484+ the fifth column reads as `member` — the containing area feature — where it485+ previously named a region.486+487+A reader refuses a version it does not know.488+489+---490+491+## 8. Stamps, names, and ordering492+493+### 8.1 The stamp494+495+A build's stamp is an **order-independent SHA-256 over named part hashes**.496+497+1. For each part, the producer records a `name` and a `hash`. A part's hash is498+ the lowercase hex SHA-256 of its bytes, except for tile pyramids, whose499+ hash is the pyramid's own derivation stamp (which already names its source500+ tiles and the tool that derived them, so tiles weigh into the stamp without501+ being read).502+2. Each part becomes the line `name + " " + hash`.503+3. The lines are **sorted lexically** and joined with `"\n"`.504+4. The stamp is the lowercase hex SHA-256 of that string.505+506+The parts a producer records are:507+508+| Part name | Hash of |509+|---|---|510+| `worlds/<slug>.json` | the encoded world payload |511+| `worlds/<slug>.bin` | the packed locations |512+| `worlds/<slug>.text` | the encoded text payload |513+| `tiles/<pyramid>` | the pyramid's derivation stamp |514+| `icons/<name>` | the icon's bytes |515+| `atlas.json` | the encoded manifest **with `version.stamp` and `version.createdAt` both empty** |516+517+That last row is the one non-obvious rule: the manifest cannot hash itself, so518+the stamp is computed over the manifest as it stands *before* the stamp and519+the capture time are filled in. `version.revision` **is** included, which is520+what makes a policy bump a new stamp.521+522+> **Constraint.** A part name must contain no space and no newline. Those are523+> the record separators of the summed form, so a name carrying either makes524+> the sum ambiguous — `"a b"` with hash `"c"` sums identically to `"a"` with525+> hash `"b c"`. Archive entry names have never held one. Widening the526+> separator would restamp every bundle in existence, so the constraint is kept527+> rather than coded around.528+529+### 8.2 The file name530+531+A bundle installed in a registry directory is named:532+533+```534+<volume-slug>-<YYYYMMDD>-<stamp12>.atlas535+```536+537+- `YYYYMMDD` is the **first eight digits of `version.createdAt`**, taken by538+ counting digits rather than parsing a time, so a name can be derived from a539+ manifest that has not been validated. A `createdAt` with no digits yields no540+ day segment.541+- `stamp12` is the first 12 hex characters of the stamp, or the whole stamp if542+ it is shorter.543+544+The name is for people and for cheap existence checks. **It is never used for545+ordering** — that is §8.3.546+547+### 8.3 Build ordering548+549+When two builds claim the same volume slug, `a` shadows `b` if:550+551+1. `a.createdAt > b.createdAt` (string comparison — RFC 3339 sorts lexically);552+ else553+2. `a.revision > b.revision`; else554+3. `a.stamp > b.stamp`; else555+4. `a`'s locator (file path) sorts after `b`'s.556+557+The comparison is **total**, so two scans of one library always agree. Step 2558+is what lets a rebuild under new policy supersede the builds already in every559+library: the data has not moved, but what a producer makes of it has. A build560+from before `revision` existed reads as `0` and loses to any revised build.561+562+---563+564+## 9. Validation — what a producer must guarantee565+566+A conforming producer runs all of these before publishing. A reader may run567+them on import; it must not run them merely to open a bundle, since they cost568+a pass over every payload.569+570+### 9.1 Manifest571+572+`format` is `atlas-bundle`; `formatVersion` is `3`; volume slug is valid and573+titled; `stamp` and `createdAt` are non-empty; at least one world is listed;574+every world slug is valid, titled, and listed once.575+576+### 9.2 Offline purity577+578+Neither `worlds/<slug>.json` nor `worlds/<slug>.text` contains `http://` or579+`https://`, anywhere, as a raw substring.580+581+### 9.3 Structure582+583+- Every listed world has all three payload entries.584+- The packed payload's location count equals `worlds[i].points`.585+- Inline path features across all path collections total `worlds[i].paths`;586+ inline area features total `worlds[i].areas`.587+- Collection ids are unique within a world.588+- Every collection's `kind` is one of `point`, `path`, `area`.589+- A point collection carries no inline features.590+- Every inline geometry type fits its collection's kind (§6.5).591+- Every path collection declares `atlas.stroke.width_px`.592+- `atlas.label.policy` appears only on area collections — only areas curate593+ their labels.594+- Every `iconAsset` names an entry that exists under `icons/`.595+- Every location's `owner` indexes an existing collection whose kind is596+ `point`.597+- The world has at least one lens; each lens's `formats` has one entry per598+ level; each level the lens claims holds at least one tile.599+600+### 9.4 Conventions — only when declared601+602+If `conventions ≥ 1`, additionally:603+604+- Every `atlas.*` key on the world, on every collection, on every inline605+ feature, and in every text entry is registered, attached to that entity, and606+ carries a value its vocabulary admits.607+- A world declaring `atlas.geometry.surface: sphere` also declares608+ `atlas.geometry.projection`, and its `equirect.*` pair parses (§6.7).609+- A collection declaring `atlas.geometry.kind` declares the same kind its610+ `kind` field does.611+- A collection declaring `atlas.icon.std` has a non-empty `iconAsset` — a612+ standard icon must be resolved by the time the bundle is written.613+614+A bundle that declares no conventions is held to none. **The strictness615+belongs to the claim.**616+617+---618+619+## 10. The registry directory620+621+A registry is a directory of immutable `.atlas` files. New builds land beside622+old ones; **nothing ever mutates a published bundle**.623+624+- **Scan** reads every `*.atlas` in the directory and takes a descriptor —625+ slug, title, stamp, createdAt, revision, size, world count — from each626+ manifest. A file that cannot be read is skipped, not fatal: one bad download627+ should not take a library down with it.628+- **Fold** picks the winning build per slug under §8.3. It is a pure function629+ of the descriptors: order-independent, filesystem-free, and always the same630+ answer for the same library.631+- **Install** validates a candidate, copies it to a temporary name in the same632+ directory, renames it into place under §8.2, then reopens and revalidates633+ the installed copy — the copy is what will serve. A build already present634+ under its own name is a no-op. Installing an old file can never overwrite a635+ newer build; it just arrives already shadowed.636+- The directory is scanned at launch and rescanned on import. It is not637+ watched: a file dropped in externally appears at next launch.638+639+### 10.1 `index.json`640+641+A registry directory may carry an `index.json` listing every volume and every642+build of it, newest first. **It is always derived from the files it sits with,643+never the other way around**, so a hand-copied or hand-deleted bundle is644+reflected by the next scan rather than contradicted by a stale listing. It is645+always safe to delete.646+647+```json648+{"games":[{"slug":"mars","title":"Mars","versions":[649+ {"file":"mars-20260801-68e141f26b1a.atlas",650+ "stamp":"68e141f2…","createdAt":"2026-08-01T14:48:10.486177Z",651+ "revision":9,"size":214003201,"maps":1}]}]}652+```653+654+Volumes sort by title; builds within a volume sort by §8.3; a volume answers655+to its newest build's title.656+657+> The wire keys `games` and `maps` are history showing through. The listing658+> predates the volume/world vocabulary and was never renamed, because nothing659+> reads it that would benefit and every tool that writes one would have to660+> agree at once. They are kept for compatibility and mean *volumes* and661+> *worlds*.662+663+---664+665+## 11. Format version history666+667+| Version | What changed |668+|---|---|669+| **1** | The original container. Manifest spoke of `game`, `maps`, `pinCount`; payloads lived under `maps/`. |670+| **2** | Renamed the format's concepts throughout — game→volume, map→world, variant→lens — in the manifest and the payload keys alike. Payloads moved to `worlds/`. |671+| **3** | Unified what a world holds. The payload's separate `groups`, `zones`, and `pins` became one order-significant `collections` array of `point`, `path`, and `area` features; the manifest counts each kind instead of counting only pins; `ATLASLOC` moved to version 3 with `owner` indexing that array. |672+673+Conventions versions move independently: `1` introduced the `atlas.*`674+vocabulary; `2` collapsed the entities `zone`, `category`, and `location` into675+`collection` and `feature` to match format 3, and renamed676+`atlas.category.key` to `atlas.collection.key`.677+678+**A reader refuses a `formatVersion` it does not know.** A library accumulates679+history, so a registry scan is expected to pass over older builds rather than680+fail on them.681+682+---683+684+## 12. Checklist for a new reader685+686+In the order you would implement them:687+688+1. Open the zip. Find `atlas.json`; refuse the archive without one.689+2. Parse the manifest. **Refuse any `formatVersion` but 3.** Ignore fields you690+ do not recognise.691+3. List worlds from `manifest.worlds`. You now have a working volume listing692+ without touching a payload.693+4. To open a world: read `worlds/<slug>.json`. Keep the `collections` array694+ **in order**.695+5. Read `worlds/<slug>.bin`. Check the magic and version, then build views696+ over the columns at the offsets in §7. Resolve each location's collection697+ by `collections[owner]`.698+6. Draw from `tiles/<lens.tiles>/<z>/<x>/<y>.<formats[z − minZoom]>`. Consult699+ `coverage` before requesting anything; fall back to the parent tile.700+7. Fetch `worlds/<slug>.text` lazily and index it by feature id as a string.701+8. Read `attrs` through the conventions registry. **Ignore every key you do702+ not know.**703+9. Assume no network. If you find yourself constructing an outbound URL from704+ payload data, the bundle is malformed or you have misread this document.@@ -1,704 +1,166 @@
1-# The `.atlas` format — version 32-3-**Status: normative.** This document specifies the `.atlas` container as it is4-frozen at format version 3, semantic conventions version 2. It is written to5-be sufficient on its own: an implementer who has never seen Atlas the6-application should be able to write a conforming reader from this document and7-nothing else. Where this document and any implementation disagree, take it as8-a defect in one of them and say so.9-10-The reference implementation is `format/bundle` and `format/semconv`, which11-depend on the Go standard library alone. The attribute vocabulary has its own12-normative document at [`semconv/REGISTRY.md`](semconv/REGISTRY.md).13-14----15-16-## 1. Vocabulary17-18-Five words carry the format. They are used precisely throughout.19-20-| Word | Meaning |21-|---|---|22-| **Volume** | The subject of one `.atlas` file: a game, a planet, a city. One file, one volume. |23-| **World** | A distinct ground within a volume that a reader can stand on. For a game these are its maps; for a captured city, its dated revisions. |24-| **Lens** | One raster tile pyramid picturing a world. Worlds are grounds; lenses are pictures of them. A world may have several lenses — map styles, dated captures, warped variants. |25-| **Collection** | An ordered group of features within a world. Every collection declares one **kind**: `point`, `path`, or `area`. |26-| **Feature** | One point, path, or area, with a title and attributes. Every feature is exactly one kind. |27-28-A **build** is one packing of a volume's data. Builds of one volume share a29-slug and are told apart by their **stamp**. Nothing else in the format is a30-noun.31-32-The format carries no trace of where a volume's data came from. A producer33-flattens whatever its sources look like into this shape; a reader only ever34-sees this shape. The one exception is the optional merge ledger (§6.6), which35-is provenance a reader may show and must never depend on.36-37----38-39-## 2. The three invariants40-41-These hold for every conforming producer and reader. Everything else in this42-document serves them.43-44-### 2.1 Determinism45-46-The same input archive, built anywhere, at any time, on any machine, produces47-the same stamp and therefore the same file name.48-49-Concretely: the stamp is an order-independent SHA-256 over named part hashes50-(§8.1); the file name is `<slug>-<YYYYMMDD>-<stamp12>.atlas` (§8.2);51-`createdAt` is the **newest capture time** across the volume's worlds, never52-the build time; and the build ordering is `createdAt`, then policy `revision`,53-then `stamp` (§8.3).54-55-### 2.2 Offline purity56-57-**No runtime URL appears anywhere in a payload.** A bundle serves with zero58-network access, forever. A producer must reject a build whose59-`worlds/<slug>.json` or `worlds/<slug>.text` contains the substring `http://`60-or `https://` (§9.2). Cross-references that a source published as URLs are61-resolved at build time to in-volume identifiers, or dropped.62-63-### 2.3 Asymmetric conventions64-65-Producers are strict; readers are lenient — with exactly one exception.66-67-- **Producers**: an `atlas.*` attribute the registry does not know, or one68- attached to the wrong entity, or one carrying a value outside its69- vocabulary, **fails the build**.70-- **Readers**: unknown attribute keys are **ignored, never refused**. A bundle71- written by a newer pipeline still opens in an older reader.72-- **The exception**: an unknown `formatVersion` is **refused outright**. A73- reader that guesses at a layout it does not know is worse than one that says74- so.75-76----77-78-## 3. The container79-80-A `.atlas` file is a **zip archive**. It holds:81-1+# The `.atlas` native schema bundle2+3+**Status: normative.** Atlas now has one accepted physical format. The retired4+document-shaped v3 format is preserved for historical reference in5+[`format-v3.md`](format-v3.md); current readers do not install it.6+7+## Semantic shape8+9+```text10+Volume11+ ├── Worlds12+ │ ├── CoordinateSpace13+ │ ├── FeatureSets14+ │ │ └── Features15+ │ │ ├── Geometry16+ │ │ ├── TypedProperties17+ │ │ ├── Relationships18+ │ │ └── Provenance19+ │ ├── RasterPyramids20+ │ └── Presentation21+ │ ├── Layers22+ │ ├── Styles23+ │ └── Legend24+ └── Assets8225 ```83-atlas.json the manifest — deflated84-worlds/<world-slug>.json world payload — deflated85-worlds/<world-slug>.bin packed point locations — STORED86-worlds/<world-slug>.text per-feature prose — deflated87-tiles/<pyramid>/<z>/<x>/<y>.<ext> raster tiles — STORED88-icons/<name> icon assets — deflated89-```90-91-### 3.1 Compression policy92-93-**Tiles and packed locations are stored uncompressed.** They are already dense94-— JPEG, PNG, WebP, packed binary — and a stored zip entry can be served as a95-straight byte range out of the archive, where a deflated one must be inflated96-on every read. This is the one performance property the container is designed97-around, and a conforming producer must honour it.98-99-JSON payloads and icons are deflated: text shrinks severalfold and is read100-whole.101-102-### 3.2 Entry ordering103-104-`atlas.json` is written first, so a listing of the archive leads with what the105-file is. A reader must find entries by name and must not depend on order for106-anything else.107-108-### 3.3 Entry names109-110-Entry names are slash-separated relative paths of plain segments. A name must111-not be empty, must not begin or end with `/`, and no segment may be empty,112-`.`, or `..`. A reader that unpacks a bundle to disk is entitled to assume113-this held, and a producer must enforce it.11426 115-### 3.4 Slugs116-117-Volume and world slugs are identifiers used unescaped as both path segments118-and URL segments. A slug matches `[a-z0-9_-]+` and must not begin with `-` or119-`_`. Nothing else is admitted: everything a hostile name could smuggle needs120-more.121-122----123-124-## 4. `atlas.json` — the manifest125-126-The manifest is what a bundle says about itself. It is read through to list a127-volume without touching any payload.128-129-**A conforming reader must refuse an archive with no `atlas.json`.** The130-reference implementation also refuses one whose uncompressed size exceeds 1131-MiB, on the grounds that a manifest lists worlds and nothing per feature.132-133-### 4.1 Schema134-135-```json136-{137- "format": "atlas-bundle",138- "formatVersion": 3,139- "conventions": 2,140- "volume": { "slug": "bend-or", "title": "Bend, Oregon" },141- "version": {142- "stamp": "ec3fe8c21cfec928d43a616eca97ebda08dc0ad7a970f531c677241bb5c70788",143- "createdAt": "2026-08-01T20:13:08.883648Z",144- "revision": 9145- },146- "tileGrid": { "sourceZoom": 13, "firstTile": 4064, "tileSize": 256, "size": 8192 },147- "worlds": [148- {149- "slug": "2026-08-01",150- "title": "2026-08-01",151- "center": { "lat": 0, "lng": 0 },152- "points": 45, "paths": 5, "areas": 16,153- "updatedAt": "2026-08-01T20:13:08.883648Z"154- }155- ]156-}27+Semantics and presentation are separate. Feature membership, geometry,28+relationships, typed claims, and evidence remain true when a presentation is29+replaced. Layers and styles refer to semantic identities; they do not own or30+duplicate features.31+32+## Physical shape33+34+A `.atlas` is a deterministic ZIP container. ZIP is only framing: the data35+model is the embedded schema plus typed column blocks, not a collection of JSON36+documents.37+38+```text39+atlas.json bootstrap + release metadata40+schema/<sha256>.json canonical file schema41+data/volumes.pack typed column blocks42+data/worlds.pack43+data/coordinate-spaces.pack44+data/feature-sets.pack45+data/property-definitions.pack46+data/features.pack47+data/relationships.pack48+data/provenance.pack49+data/raster-pyramids.pack50+data/raster-formats.pack51+data/raster-coverage.pack52+data/presentations.pack53+data/styles.pack54+data/layers.pack55+data/legend.pack56+data/assets.pack57+assets/<sha256> content-addressed supporting bytes58+tiles/<pyramid>/<z>/<x>/<y>.<ext> opaque raster bytes15759 ```15860 159-| Field | Type | Required | Meaning |160-|---|---|---|---|161-| `format` | string | yes | Always `"atlas-bundle"`. A reader refuses any other value, so a stray zip renamed to `.atlas` fails by name rather than by whatever breaks first. |162-| `formatVersion` | integer | yes | `3`. A reader refuses versions it does not know (§2.3). |163-| `conventions` | integer | omitted when 0 | The semantic-conventions vocabulary the payloads were written against. `2` is current; absent means a bundle from before the conventions existed. A **declaration**, never a gate: a reader that knows a different vocabulary still opens the bundle. Validation is stricter about a bundle that declares (§9.4). |164-| `volume.slug` | slug | yes | The volume's identity. Two bundles naming the same slug are two builds of the same volume, however their files are named. |165-| `volume.title` | string | yes, non-empty | Display name. |166-| `version.stamp` | hex string | yes, non-empty | Content fingerprint (§8.1). Two bundles with equal stamps hold the same data. |167-| `version.createdAt` | RFC 3339 | yes, non-empty | The newest capture time across the volume's worlds. **Never the build time.** |168-| `version.revision` | integer | omitted when 0 | Policy revision: which generation of build rules produced this file. Orders builds of one capture (§8.3). |169-| `tileGrid` | object | yes | The window this volume's worlds are cut from (§4.2). |170-| `worlds` | array | yes, non-empty | One entry per world (§4.3). Order is the offering order. |171-172-### 4.2 `tileGrid`173-174-| Field | Type | Meaning |175-|---|---|---|176-| `sourceZoom` | integer | The zoom level the source raster was captured at. |177-| `firstTile` | integer | The tile column/row the captured window begins at, in the source grid. |178-| `tileSize` | integer | Pixels per tile edge. `256` throughout. |179-| `size` | integer | The world square's edge in world pixels. `8192` throughout. |180-181-A world cut from a window of its own overrides `sourceZoom` and `firstTile` in182-its own payload (§6.2).183-184-### 4.3 `worlds[]`185-186-| Field | Type | Required | Meaning |187-|---|---|---|---|188-| `slug` | slug | yes | Names the world's three payload entries and is the world's identity everywhere. Must be unique within the manifest. |189-| `title` | string | yes, non-empty | Display name. |190-| `parent` | slug | omitted when empty | The world this one was split out of, so an inset sorts with the sheet it came from. |191-| `iconOutset` | string | omitted when empty | `light` or `dark`: the rim markers wear to stay legible against this world's art. Duplicated as `atlas.icon.outset` in the payload. |192-| `center` | `{lat, lng}` | yes | Opening view, in the volume's own projection. |193-| `points`, `paths`, `areas` | integer | yes | Feature counts by kind. Every feature is exactly one of the three, so a listing can say how much a world holds without opening it — and validation holds the payload to the promise (§9.3). |194-| `updatedAt` | RFC 3339 | yes | This world's capture time. The manifest's `createdAt` is the newest of these. |195-196-### 4.4 Manifest encoding is normative197-198-The manifest's **encoded bytes** feed the stamp (§8.1), so its encoding is part199-of the format, not an implementation detail:200-201-- Field order is exactly as tabled above.202-- `conventions` and `version.revision` are omitted when zero. **Every other203- field is always present**, including zero-valued ones (`"points":0`,204- `"center":{"lat":0,"lng":0}`, `"updatedAt":""`).205-- Compact — no indentation, no trailing newline.206-- HTML-sensitive characters in strings are escaped to their `\u` forms:207- `<` becomes `\u003c`, `>` becomes `\u003e`, `&` becomes `\u0026`. This is208- Go's `encoding/json` default and it is load-bearing — a volume titled209- `Hospitals & Care` is encoded with `\u0026` in every published bundle, and210- an encoder that emits a bare `&` computes a different stamp.211-212-An implementation that re-encodes a manifest and gets different bytes will213-compute different stamps and produce differently named files.214-215----61+Typed blocks and opaque blobs are stored without ZIP compression so they can62+be read independently. The bootstrap and schema may be deflated. Entry names63+are relative, unique, and cannot contain empty, `.` or `..` segments.21664 217-## 5. World payload entries65+## Bootstrap and release identity21866 219-Each world splits three ways, by when its parts are needed:220-221-| Entry | Read when | Holds |222-|---|---|---|223-| `worlds/<slug>.json` | the world opens | lenses, collections, inline shape geometry, world attributes, merge ledger |224-| `worlds/<slug>.bin` | the world opens | every point feature, packed |225-| `worlds/<slug>.text` | a feature's card opens | descriptions, links, per-feature attributes |226-227-The split exists because descriptions were half the payload by weight and are228-read one feature at a time, while point features are overwhelmingly numbers,229-and numbers written as text cost several times what they measure. Shape230-features are mostly geometry either way, so they ride the JSON inline.231-232----233-234-## 6. `worlds/<slug>.json` — the world payload67+`atlas.json` is deliberately small and schema-independent:23568 23669 ```json23770 {238- "grid": { "sourceZoom": 5, "firstTile": 0 },239- "lenses": [ … ],240- "collections": [ … ],241- "attrs": { "atlas.geometry.surface": "sphere", … },242- "merged": [ … ]243-}244-```245-246-| Field | Required | Meaning |247-|---|---|---|248-| `grid` | optional | Window override (§6.2). Present only on a world cut from a window that is not the manifest's. |249-| `lenses` | yes, non-empty | The pictures of this world (§6.3). |250-| `collections` | yes | **Order-significant** (§6.4). May be empty. |251-| `attrs` | optional | The world speaking the shared conventions, entity `world`. |252-| `merged` | optional | Provenance ledger (§6.6). A reader may show it and must never depend on it. |253-254-### 6.1 Attributes generally255-256-`attrs` objects appear on the world, on each collection, and on each feature.257-They are flat `string → string` maps. Keys in the `atlas.` namespace are258-governed by [`semconv/REGISTRY.md`](semconv/REGISTRY.md); keys outside it are259-not the registry's business and pass through. **A reader ignores any key it260-does not know** (§2.3).261-262-### 6.2 `grid`263-264-```json265-{ "sourceZoom": 5, "firstTile": 0 }266-```267-268-Overrides the manifest's `tileGrid.sourceZoom` and `tileGrid.firstTile` for269-this world. `tileSize` and `size` are never overridden.270-271-### 6.3 `lenses[]`272-273-One raster pyramid picturing this world.274-275-| Field | Type | Required | Meaning |276-|---|---|---|---|277-| `name` | string | yes | Display name of the picture, e.g. `"Viking MDIM 2.1"`, `"Basemap"`. |278-| `tiles` | string | yes | The pyramid's directory under `tiles/`. Several lenses may name one pyramid. |279-| `minZoom` | integer | yes | Lowest zoom the pyramid holds. **`0` in every published bundle.** |280-| `maxZoom` | integer | yes | Highest zoom the pyramid holds. |281-| `fullZoom` | integer | yes | The highest zoom at which the pyramid is *complete*. Above it, tiles exist only where the capture reached, and `coverage` says where. |282-| `sourceZoom` | integer | yes | The zoom this pyramid's source raster was captured at. |283-| `formats` | array of string | yes | One file extension per level, indexed by `z − minZoom`: `formats[z − minZoom]` is the extension of every tile at zoom `z`. Length must equal `maxZoom − minZoom + 1`. |284-| `bounds` | `{x,y,width,height}` | optional | The window of world pixels the pyramid's tiles cover. |285-| `surface` | `{x,y,width,height}` | optional | The ground the world actually covers, where `bounds` is the window cut to draw it. On a piece of a split sheet the window is grown to take in a title drawn beside it, so anything dividing the world up measures `surface` and leaves no cell on margin. |286-| `interpolate` | boolean | yes | `true` to resample smoothly (photographic rasters); `false` for nearest-neighbour (pixel art). |287-| `background` | string | optional | CSS colour behind the raster, e.g. `"#14181d"`. |288-| `shard` | integer | omitted when 0 | Which layer of a split world this lens draws. Features carry a matching `shard`. |289-| `coverage` | object | optional | Sparse-level bitsets (§6.3.1). Absent for a level means that level is fully covered. |290-291-#### 6.3.1 `coverage`292-293-```json294-"coverage": { "3": { "x": 1, "y": 0, "w": 7, "h": 8, "bits": "YDDc//v5cA==" } }295-```296-297-Keyed by **zoom level as a decimal string**. Each value describes which tiles298-of that level were actually written:299-300-- `x`, `y`, `w`, `h` — the bounding tile window at that level.301-- `bits` — **standard base64** of a row-major bitset over that window, **least302- significant bit first within each byte**.303-304-To test whether tile `(z, x, y)` exists:305-306-```307-c = x − coverage.x; r = y − coverage.y308-if c < 0 or r < 0 or c >= coverage.w or r >= coverage.h: absent309-i = r * coverage.w + c310-present ⟺ (bits[i >> 3] & (1 << (i & 7))) != 0311-```312-313-A level with no `coverage` entry is fully covered. A reader must consult the314-bitset before requesting a tile: asking for one that was never written is a315-wasted request, and the correct fallback is to draw the parent tile.316-317-### 6.4 `collections[]` — order is significant318-319-The collections array is the world's whole structure in one flat list.320-**Its order is load-bearing**: the packed payload's `owner` column indexes it321-(§7).322-323-Point collections are written first and shape collections after, so a packed324-location's `owner` is simply the ordinal of its collection among the points.325-The array order is also the legend order.326-327-| Field | Type | Required | Meaning |328-|---|---|---|---|329-| `id` | integer | yes | Stable identifier, unique within the world. |330-| `title` | string | yes | Display name. |331-| `group` | string | optional | A heading several collections sit under. |332-| `kind` | string | yes | `point`, `path`, or `area`. |333-| `icon` | string | optional | The collection's icon key, before resolution. |334-| `iconAsset` | string | optional | The resolved icon's name under `icons/`. Must exist in the archive. |335-| `iconPicture` | boolean | omitted when false | The asset is a picture drawn as-is rather than a glyph the reader tints. |336-| `color`, `iconColor` | string | optional | CSS colours. |337-| `visible` | boolean | yes | Whether the collection is drawn when the world opens. |338-| `attrs` | object | optional | Conventions, entity `collection`. |339-| `features` | array | optional | Inline features. **A point collection carries none** — its members ride the packed payload. Path and area collections carry theirs here. |340-341-### 6.5 `features[]` — inline shape features342-343-Present only on `path` and `area` collections.344-345-| Field | Type | Required | Meaning |346-|---|---|---|---|347-| `id` | integer | yes | Unique within the world, across *all* kinds — one world never keys two things alike. |348-| `title` | string | yes | Display name. |349-| `subtitle` | string | optional | Secondary line. |350-| `hasText` | boolean | omitted when false | This feature has an entry in `worlds/<slug>.text`. |351-| `parent` | integer | optional | The id of a containing feature. |352-| `center` | `{lat,lng}` | optional | A representative point, for framing and labelling. |353-| `shard` | integer | omitted when 0 | Which layer of a split world this feature belongs to. |354-| `geometry` | array | yes | GeoJSON-shaped geometry objects: `{"type": …, "coordinates": …}`. |355-| `attrs` | object | optional | Conventions, entity `feature`. |356-357-Geometry types must match the collection's kind:358-359-| Kind | Admitted geometry types |360-|---|---|361-| `area` | `Polygon`, `MultiPolygon` |362-| `path` | `LineString`, `MultiLineString` |363-| `point` | *none* — points are never inlined |364-365-Coordinates are in the volume's own world space (the same `lat`/`lng`366-convention as `center`), not WGS 84. A world that declares a spherical surface367-also declares the mapping from world space to true coordinates (§6.7).368-369-### 6.6 `merged[]` — the provenance ledger370-371-Optional. Present when a build folded more than one source together, or to372-record where a single-source world came from.373-374-```json375-[{ "source": "ArcGIS Open Data", "slug": "arcgis-hub", "origin": true,376- "donorFeatures": {"point": 45, "path": 5, "area": 16}, "added": 0 }]377-```378-379-| Field | Meaning |380-|---|---|381-| `source` | Display name of the contributing source. |382-| `slug` | The source's canonical name, so ledgers and tooling agree without translation. |383-| `origin` | This entry accounts for the source the world itself came from. Its `donorFeatures` is the world's own tally at composition. |384-| `donorFeatures` | `{point, path, area}` — the donor's whole offering, counted per kind. |385-| `matched`, `added`, `addedShapes`, `adopted`, `held`, `rejected`, `enrichedCategories`, `alignment` | The merge's own account: what met an existing feature, what was new, what was held back and why. Producer-defined; a reader displays what it recognises. |386-387-**A reader must never depend on the ledger.** It is provenance a curious388-person reads, not data the picture needs.389-390-### 6.7 Spherical worlds391-392-A world whose raster pictures a sphere declares so in `attrs`:393-394-```json395-"attrs": {396- "atlas.geometry.surface": "sphere",397- "atlas.geometry.projection": "equirect",398- "atlas.geometry.equirect.px": "0,0,8192,4096",399- "atlas.geometry.equirect.deg": "-180,90,180,-90",400- "atlas.geometry.body": "mars",401- "atlas.geometry.radius_km": "3389.5"71+ "format": "atlas-schema-bundle",72+ "framing": 1,73+ "volume": "earth",74+ "release": {75+ "title": "Earth",76+ "createdAt": "2026-08-03T16:21:07Z",77+ "revision": 10,78+ "stamp": "<64 lowercase hex characters>",79+ "worlds": 180+ },81+ "schema": { "name": "schema/<hash>.json", "hash": "<sha256>" },82+ "tables": [],83+ "blobs": []40284 }40385 ```40486 405-`equirect.px` is `x,y,w,h` in world pixels; `equirect.deg` is406-`west,north,east,south` in degrees. The mapping is linear in both axes:407-408-```409-x = X + (lon − West) / (East − West) * W410-y = Y + (North − lat) / (North − South) * H411-412-lon = West + (x − X) / W * (East − West)413-lat = North − (y − Y) / H * (North − South)414-```415-416-West may exceed east numerically (a world centred on the antimeridian); the417-span still reads west to east. `w` and `h` must be positive, and north must418-differ from south and west from east.87+- `createdAt` is newest source capture time, never build time.88+- `revision` orders different producer policies over the same capture.89+- `stamp` is SHA-256 over the canonical bootstrap with an empty stamp, after90+ all schema, table, and blob hashes and lengths have been filled.91+- file names are `<volume>-<YYYYMMDD>-<stamp12>.atlas`.92+- registry winners order by `createdAt`, `revision`, `stamp`, then locator.41993 420-A world that says nothing about its surface is a **plane**. A world declaring421-`sphere` **must** also declare `projection` and both `equirect.*` keys (§9.4).94+The directory is the registry. `index.json`, when present, is a derived95+convenience listing with no authority.42296 423-The `atlas.geometry.mercator.*` keys declare a Web-Mercator cut instead: `y`424-is linear in the projected latitude, `asinh(tan lat)`, rather than in degrees.425-They are experimental.97+## Typed block framing42698 427----99+Every `.pack` begins with `ATLASPK\0`, framing version 1, row and column counts,100+the canonical schema hash, the root type identity, and a fixed-width column101+directory. Each directory entry declares:428102 429-## 7. `worlds/<slug>.bin` — `ATLASLOC` version 3103+- stable 128-bit field identity;104+- logical and physical kind;105+- optionality;106+- presence bitmap range;107+- payload range;108+- CRC-32 over presence and payload.430109 431-Every point feature of a world, packed as parallel little-endian typed arrays.432-The layout exists so a reader can build typed-array views directly over the433-downloaded buffer, with no parsing and no per-feature allocation.110+Fixed-width values are directly addressable. Strings and bytes use a row111+offset table followed by content. Consumers verify bounds and checksums before112+exposing column views.434113 435-**All integers and floats are little-endian.** Let `N` be the location count.114+## Compatibility without a committee436115 437-| Offset | Size | Type | Column |438-|---|---|---|---|439-| `0` | 8 | bytes | Magic: ASCII `ATLASLOC` |440-| `8` | 2 | `uint16` | Version. `3`. |441-| `10` | 4 | `uint32` | `N`, the location count |442-| `14` | 2 | — | Reserved, zero. Present so every column that follows is four-byte aligned. |443-| `16` | `4N` | `int32` | **id** |444-| `16 + 4N` | `4N` | `float32` | **lat** |445-| `16 + 8N` | `4N` | `float32` | **lng** |446-| `16 + 12N` | `4N` | `int32` | **member** |447-| `16 + 16N` | `4N` | `int32` | **shard** |448-| `16 + 20N` | `4(N+1)` | `uint32` | **title offsets** |449-| `20 + 24N` | `2N` | `uint16` | **owner** |450-| `20 + 26N` | variable | bytes | **title bytes** |116+Identity, not field position or spelling, governs meaning.451117 452-Total size is `20 + 26N + Σ len(title_i)`.453-454-### 7.1 Columns455-456-| Column | Meaning |118+| Change | Result |457119 |---|---|458-| **id** | The feature's identifier, unique within the world across all kinds. Signed 32-bit on the wire. |459-| **lat**, **lng** | Position in the volume's world space. Single precision, which resolves far finer than a world is drawn. A conforming reader must expect coordinates to be float32-rounded. |460-| **member** | The id of the **area feature containing this point**, or `0` for none. |461-| **shard** | Which layer of a split world the point belongs to, or `0` when the world is whole. |462-| **owner** | **The index of this feature's collection in the world payload's `collections` array.** This is the only collection identity a reader needs, and it is why that array's order is significant. `owner` must index an existing collection whose `kind` is `point`. |463-| **title** | See below. |464-465-### 7.2 Titles466-467-The title offsets column holds `N+1` byte offsets into the title region,468-relative to its start. Location `i`'s title is469-`titles[offsets[i] : offsets[i+1]]`, as **UTF-8, not NUL-terminated**. Offsets470-are non-decreasing; `offsets[0]` is `0` and `offsets[N]` is the length of the471-title region. An empty title is a zero-length run, which is legal.472-473-A conforming reader should verify that the offsets are non-decreasing and that474-`offsets[N]` does not exceed the remaining bytes, because these are the only475-values in the payload that can point outside it.476-477-### 7.3 Version history478-479-- **1** — the original packing.480-- **2** — added the `shard` column. A world split into layers offers one at a481- time; without it every layer's locations drew over every other.482-- **3** — moved meaning without moving a byte. `owner` now indexes the world483- payload's `collections` array rather than a flattened category order, and484- the fifth column reads as `member` — the containing area feature — where it485- previously named a region.486-487-A reader refuses a version it does not know.488-489----490-491-## 8. Stamps, names, and ordering492-493-### 8.1 The stamp494-495-A build's stamp is an **order-independent SHA-256 over named part hashes**.496-497-1. For each part, the producer records a `name` and a `hash`. A part's hash is498- the lowercase hex SHA-256 of its bytes, except for tile pyramids, whose499- hash is the pyramid's own derivation stamp (which already names its source500- tiles and the tool that derived them, so tiles weigh into the stamp without501- being read).502-2. Each part becomes the line `name + " " + hash`.503-3. The lines are **sorted lexically** and joined with `"\n"`.504-4. The stamp is the lowercase hex SHA-256 of that string.505-506-The parts a producer records are:507-508-| Part name | Hash of |509-|---|---|510-| `worlds/<slug>.json` | the encoded world payload |511-| `worlds/<slug>.bin` | the packed locations |512-| `worlds/<slug>.text` | the encoded text payload |513-| `tiles/<pyramid>` | the pyramid's derivation stamp |514-| `icons/<name>` | the icon's bytes |515-| `atlas.json` | the encoded manifest **with `version.stamp` and `version.createdAt` both empty** |516-517-That last row is the one non-obvious rule: the manifest cannot hash itself, so518-the stamp is computed over the manifest as it stands *before* the stamp and519-the capture time are filled in. `version.revision` **is** included, which is520-what makes a policy bump a new stamp.521-522-> **Constraint.** A part name must contain no space and no newline. Those are523-> the record separators of the summed form, so a name carrying either makes524-> the sum ambiguous — `"a b"` with hash `"c"` sums identically to `"a"` with525-> hash `"b c"`. Archive entry names have never held one. Widening the526-> separator would restamp every bundle in existence, so the constraint is kept527-> rather than coded around.528-529-### 8.2 The file name530-531-A bundle installed in a registry directory is named:532-120+| Add a type with a new ID | compatible |121+| Add an optional field with a new ID | compatible |122+| Rename a type or field while keeping its ID and kind | compatible |123+| Independently add different IDs on two branches | union is compatible |124+| Reuse an ID with a different kind | refused |125+| Remove or reinterpret a required field | new identity required |126+| Change container framing | old reader refuses |127+128+A reader merges its application schema with the file schema. Unknown types and129+columns remain addressable and round-trippable; a presentation may ignore them.130+This gives backward, forward, and sideward compatibility without a central131+sequence of schema versions.132+133+## Install and validation134+135+One command serves picker, desktop open, drag/drop, and programmatic install:136+137+```text138+source → open framing → merge schema → verify every table/blob139+ → restore and validate semantic Volume140+ → stage beside library → atomic rename → reopen + verify141+ → rescan + deterministic fold → announce changed volume533142 ```534-<volume-slug>-<YYYYMMDD>-<stamp12>.atlas535-```536-537-- `YYYYMMDD` is the **first eight digits of `version.createdAt`**, taken by538- counting digits rather than parsing a time, so a name can be derived from a539- manifest that has not been validated. A `createdAt` with no digits yields no540- day segment.541-- `stamp12` is the first 12 hex characters of the stamp, or the whole stamp if542- it is shorter.543-544-The name is for people and for cheap existence checks. **It is never used for545-ordering** — that is §8.3.546-547-### 8.3 Build ordering548-549-When two builds claim the same volume slug, `a` shadows `b` if:550-551-1. `a.createdAt > b.createdAt` (string comparison — RFC 3339 sorts lexically);552- else553-2. `a.revision > b.revision`; else554-3. `a.stamp > b.stamp`; else555-4. `a`'s locator (file path) sorts after `b`'s.556-557-The comparison is **total**, so two scans of one library always agree. Step 2558-is what lets a rebuild under new policy supersede the builds already in every559-library: the data has not moved, but what a producer makes of it has. A build560-from before `revision` existed reads as `0` and loses to any revised build.561-562----563-564-## 9. Validation — what a producer must guarantee565-566-A conforming producer runs all of these before publishing. A reader may run567-them on import; it must not run them merely to open a bundle, since they cost568-a pass over every payload.569-570-### 9.1 Manifest571-572-`format` is `atlas-bundle`; `formatVersion` is `3`; volume slug is valid and573-titled; `stamp` and `createdAt` are non-empty; at least one world is listed;574-every world slug is valid, titled, and listed once.575-576-### 9.2 Offline purity577-578-Neither `worlds/<slug>.json` nor `worlds/<slug>.text` contains `http://` or579-`https://`, anywhere, as a raw substring.580-581-### 9.3 Structure582-583-- Every listed world has all three payload entries.584-- The packed payload's location count equals `worlds[i].points`.585-- Inline path features across all path collections total `worlds[i].paths`;586- inline area features total `worlds[i].areas`.587-- Collection ids are unique within a world.588-- Every collection's `kind` is one of `point`, `path`, `area`.589-- A point collection carries no inline features.590-- Every inline geometry type fits its collection's kind (§6.5).591-- Every path collection declares `atlas.stroke.width_px`.592-- `atlas.label.policy` appears only on area collections — only areas curate593- their labels.594-- Every `iconAsset` names an entry that exists under `icons/`.595-- Every location's `owner` indexes an existing collection whose kind is596- `point`.597-- The world has at least one lens; each lens's `formats` has one entry per598- level; each level the lens claims holds at least one tile.599-600-### 9.4 Conventions — only when declared601-602-If `conventions ≥ 1`, additionally:603-604-- Every `atlas.*` key on the world, on every collection, on every inline605- feature, and in every text entry is registered, attached to that entity, and606- carries a value its vocabulary admits.607-- A world declaring `atlas.geometry.surface: sphere` also declares608- `atlas.geometry.projection`, and its `equirect.*` pair parses (§6.7).609-- A collection declaring `atlas.geometry.kind` declares the same kind its610- `kind` field does.611-- A collection declaring `atlas.icon.std` has a non-empty `iconAsset` — a612- standard icon must be resolved by the time the bundle is written.613-614-A bundle that declares no conventions is held to none. **The strictness615-belongs to the claim.**616-617----618-619-## 10. The registry directory620-621-A registry is a directory of immutable `.atlas` files. New builds land beside622-old ones; **nothing ever mutates a published bundle**.623-624-- **Scan** reads every `*.atlas` in the directory and takes a descriptor —625- slug, title, stamp, createdAt, revision, size, world count — from each626- manifest. A file that cannot be read is skipped, not fatal: one bad download627- should not take a library down with it.628-- **Fold** picks the winning build per slug under §8.3. It is a pure function629- of the descriptors: order-independent, filesystem-free, and always the same630- answer for the same library.631-- **Install** validates a candidate, copies it to a temporary name in the same632- directory, renames it into place under §8.2, then reopens and revalidates633- the installed copy — the copy is what will serve. A build already present634- under its own name is a no-op. Installing an old file can never overwrite a635- newer build; it just arrives already shadowed.636-- The directory is scanned at launch and rescanned on import. It is not637- watched: a file dropped in externally appears at next launch.638-639-### 10.1 `index.json`640-641-A registry directory may carry an `index.json` listing every volume and every642-build of it, newest first. **It is always derived from the files it sits with,643-never the other way around**, so a hand-copied or hand-deleted bundle is644-reflected by the next scan rather than contradicted by a stale listing. It is645-always safe to delete.646-647-```json648-{"games":[{"slug":"mars","title":"Mars","versions":[649- {"file":"mars-20260801-68e141f26b1a.atlas",650- "stamp":"68e141f2…","createdAt":"2026-08-01T14:48:10.486177Z",651- "revision":9,"size":214003201,"maps":1}]}]}652-```653-654-Volumes sort by title; builds within a volume sort by §8.3; a volume answers655-to its newest build's title.656-657-> The wire keys `games` and `maps` are history showing through. The listing658-> predates the volume/world vocabulary and was never renamed, because nothing659-> reads it that would benefit and every tool that writes one would have to660-> agree at once. They are kept for compatibility and mean *volumes* and661-> *worlds*.662-663----664-665-## 11. Format version history666-667-| Version | What changed |668-|---|---|669-| **1** | The original container. Manifest spoke of `game`, `maps`, `pinCount`; payloads lived under `maps/`. |670-| **2** | Renamed the format's concepts throughout — game→volume, map→world, variant→lens — in the manifest and the payload keys alike. Payloads moved to `worlds/`. |671-| **3** | Unified what a world holds. The payload's separate `groups`, `zones`, and `pins` became one order-significant `collections` array of `point`, `path`, and `area` features; the manifest counts each kind instead of counting only pins; `ATLASLOC` moved to version 3 with `owner` indexing that array. |672143 673-Conventions versions move independently: `1` introduced the `atlas.*`674-vocabulary; `2` collapsed the entities `zone`, `category`, and `location` into675-`collection` and `feature` to match format 3, and renamed676-`atlas.category.key` to `atlas.collection.key`.144+A malformed file never lands under `.atlas`. A legacy v3 file, ordinary ZIP,145+unknown framing, inconsistent schema, changed field kind, corrupt column, bad146+hash, missing reference, or invalid semantic graph is refused.677147 678-**A reader refuses a `formatVersion` it does not know.** A library accumulates679-history, so a registry scan is expected to pass over older builds rather than680-fail on them.148+## Producer requirements681149 682----150+The producer constructs the semantic graph directly and calls the native151+compiler. It must not create v3 JSON payloads as an intermediate format.152+Raster files may be streamed from disk; their hashes and lengths still153+participate in the release stamp. A completed file is reopened and fully154+validated before it is renamed into the registry.683155 684-## 12. Checklist for a new reader156+## Reader requirements685157 686-In the order you would implement them:158+A conforming reader:687159 688-1. Open the zip. Find `atlas.json`; refuse the archive without one.689-2. Parse the manifest. **Refuse any `formatVersion` but 3.** Ignore fields you690- do not recognise.691-3. List worlds from `manifest.worlds`. You now have a working volume listing692- without touching a payload.693-4. To open a world: read `worlds/<slug>.json`. Keep the `collections` array694- **in order**.695-5. Read `worlds/<slug>.bin`. Check the magic and version, then build views696- over the columns at the offsets in §7. Resolve each location's collection697- by `collections[owner]`.698-6. Draw from `tiles/<lens.tiles>/<z>/<x>/<y>.<formats[z − minZoom]>`. Consult699- `coverage` before requesting anything; fall back to the parent tile.700-7. Fetch `worlds/<slug>.text` lazily and index it by feature id as a string.701-8. Read `attrs` through the conventions registry. **Ignore every key you do702- not know.**703-9. Assume no network. If you find yourself constructing an outbound URL from704- payload data, the bundle is malformed or you have misread this document.160+1. validates bootstrap identity, framing, release metadata, and release stamp;161+2. verifies and canonicalizes the embedded schema;162+3. unions file and application schemas by stable identity;163+4. verifies table roots, column kinds, required fields, bounds, and CRCs;164+5. verifies opaque blob length and SHA-256 before use;165+6. restores references by semantic ID and validates the complete graph;166+7. treats Presentation as a view over semantic data, not as the data itself.@@ -37,11 +37,13 @@ type NamedTable struct {
3737 type Blob struct {3838 Name string3939 Data []byte40+ Path string4041 }4142 4243 // Bundle is the complete compiled form accepted by Write.4344 type Bundle struct {4445 VolumeID string46+ Release Release4547 Schema Schema4648 Tables []NamedTable4749 Blobs []Blob@@ -117,7 +119,13 @@ func Compile(volume Volume) (Bundle, error) {
117119 }118120 tables[index] = NamedTable{Name: name, Table: table}119121 }120- return Bundle{VolumeID: volume.ID, Schema: schema, Tables: tables, Blobs: blobs}, nil122+ return Bundle{123+ VolumeID: volume.ID,124+ Release: Release{Title: volume.Title, Worlds: len(volume.Worlds)},125+ Schema: schema,126+ Tables: tables,127+ Blobs: blobs,128+ }, nil121129 }122130 123131 func schemaFor(volume Volume) (Schema, error) {@@ -8,6 +8,7 @@ import (
88 "encoding/json"99 "fmt"1010 "io"11+ "os"1112 "slices"1213 "strings"1314 "time"@@ -42,23 +43,31 @@ type bootstrap struct {
4243 Format string `json:"format"`4344 Framing uint16 `json:"framing"`4445 Volume string `json:"volume"`46+ Release Release `json:"release"`4547 Schema schemaReference `json:"schema"`4648 Tables []tableReference `json:"tables"`4749 Blobs []blobReference `json:"blobs,omitempty"`4850 }4951 5052 type preparedEntry struct {51- name string52- data []byte53- method uint1653+ name string54+ data []byte55+ sourcePath string56+ method uint165457 }5558 5659 // Write produces one deterministic ZIP container with a small JSON bootstrap,5760 // an embedded canonical schema, stored typed blocks, and stored opaque blobs.5861 func Write(out io.Writer, bundle Bundle) error {59- if bundle.VolumeID == "" {60- return fmt.Errorf("bundle has no volume identity")62+ if err := ValidSlug(bundle.VolumeID); err != nil {63+ return fmt.Errorf("volume identity: %w", err)6164 }65+ if err := bundle.Release.validate(false); err != nil {66+ return fmt.Errorf("release: %w", err)67+ }68+ // The stamp is always derived here; callers cannot smuggle a stale identity69+ // across a changed schema or blob set.70+ bundle.Release.Stamp = ""6271 schemaData, err := bundle.Schema.Canonical()6372 if err != nil {6473 return fmt.Errorf("canonicalize schema: %w", err)@@ -69,6 +78,12 @@ func Write(out io.Writer, bundle Bundle) error {
6978 if err != nil {7079 return err7180 }81+ stampInput, err := json.Marshal(manifest)82+ if err != nil {83+ return fmt.Errorf("marshal stamp input: %w", err)84+ }85+ stamp := sha256.Sum256(stampInput)86+ manifest.Release.Stamp = hex.EncodeToString(stamp[:])7287 manifestData, err := json.Marshal(manifest)7388 if err != nil {7489 return fmt.Errorf("marshal bootstrap: %w", err)@@ -89,7 +104,8 @@ func Write(out io.Writer, bundle Bundle) error {
89104 func prepareBundle(bundle Bundle, schemaHash [32]byte, schemaName string, schemaData []byte) (bootstrap, []preparedEntry, error) {90105 manifest := bootstrap{91106 Format: containerFormat, Framing: containerFraming, Volume: bundle.VolumeID,92- Schema: schemaReference{Name: schemaName, Hash: hex.EncodeToString(schemaHash[:])},107+ Release: bundle.Release,108+ Schema: schemaReference{Name: schemaName, Hash: hex.EncodeToString(schemaHash[:])},93109 }94110 entries := []preparedEntry{{name: schemaName, data: schemaData, method: zip.Deflate}}95111 for _, named := range bundle.Tables {@@ -105,17 +121,41 @@ func prepareBundle(bundle Bundle, schemaHash [32]byte, schemaName string, schema
105121 entries = append(entries, preparedEntry{name: named.Name, data: data, method: zip.Store})106122 }107123 for _, blob := range bundle.Blobs {108- digest := sha256.Sum256(blob.Data)124+ hash, length, err := blobDigest(blob)125+ if err != nil {126+ return bootstrap{}, nil, fmt.Errorf("prepare %s: %w", blob.Name, err)127+ }109128 manifest.Blobs = append(manifest.Blobs, blobReference{110- Name: blob.Name, Hash: hex.EncodeToString(digest[:]), Length: int64(len(blob.Data)),129+ Name: blob.Name, Hash: hash, Length: length,111130 })112- entries = append(entries, preparedEntry{name: blob.Name, data: blob.Data, method: zip.Store})131+ entries = append(entries, preparedEntry{name: blob.Name, data: blob.Data, sourcePath: blob.Path, method: zip.Store})113132 }114133 slices.SortFunc(manifest.Tables, func(left, right tableReference) int { return strings.Compare(left.Name, right.Name) })115134 slices.SortFunc(manifest.Blobs, func(left, right blobReference) int { return strings.Compare(left.Name, right.Name) })116135 return manifest, entries, nil117136 }118137 138+func blobDigest(blob Blob) (string, int64, error) {139+ if blob.Path == "" {140+ digest := sha256.Sum256(blob.Data)141+ return hex.EncodeToString(digest[:]), int64(len(blob.Data)), nil142+ }143+ if len(blob.Data) != 0 {144+ return "", 0, fmt.Errorf("blob has both data and a source path")145+ }146+ file, err := os.Open(blob.Path)147+ if err != nil {148+ return "", 0, err149+ }150+ defer file.Close()151+ digest := sha256.New()152+ length, err := io.Copy(digest, file)153+ if err != nil {154+ return "", 0, err155+ }156+ return hex.EncodeToString(digest.Sum(nil)), length, nil157+}158+119159 func writeArchive(out io.Writer, entries []preparedEntry) error {120160 archive := zip.NewWriter(out)121161 seen := make(map[string]bool, len(entries))@@ -133,6 +173,21 @@ func writeArchive(out io.Writer, entries []preparedEntry) error {
133173 if err != nil {134174 return fmt.Errorf("create %s: %w", entry.name, err)135175 }176+ if entry.sourcePath != "" {177+ source, err := os.Open(entry.sourcePath)178+ if err != nil {179+ return fmt.Errorf("open %s: %w", entry.name, err)180+ }181+ _, copyErr := io.Copy(writer, source)182+ closeErr := source.Close()183+ if copyErr != nil {184+ return fmt.Errorf("write %s: %w", entry.name, copyErr)185+ }186+ if closeErr != nil {187+ return fmt.Errorf("close %s: %w", entry.name, closeErr)188+ }189+ continue190+ }136191 if _, err := writer.Write(entry.data); err != nil {137192 return fmt.Errorf("write %s: %w", entry.name, err)138193 }@@ -147,6 +202,7 @@ func writeArchive(out io.Writer, entries []preparedEntry) error {
147202 // application's merged runtime schema.148203 type Reader struct {149204 VolumeID string205+ Release Release150206 FileSchema Schema151207 RuntimeSchema Schema152208 @@ -179,7 +235,7 @@ func Open(source io.ReaderAt, size int64, applicationSchema Schema) (*Reader, er
179235 return nil, fmt.Errorf("merge runtime schema: %w", err)180236 }181237 reader := &Reader{182- VolumeID: manifest.Volume, FileSchema: fileSchema, RuntimeSchema: runtimeSchema,238+ VolumeID: manifest.Volume, Release: manifest.Release, FileSchema: fileSchema, RuntimeSchema: runtimeSchema,183239 entries: entries, tables: make(map[string]tableReference, len(manifest.Tables)),184240 blobs: make(map[string]blobReference, len(manifest.Blobs)),185241 }@@ -212,9 +268,26 @@ func readBootstrap(entries map[string]*zip.File) (bootstrap, error) {
212268 if err := json.Unmarshal(data, &manifest); err != nil {213269 return bootstrap{}, fmt.Errorf("decode bootstrap: %w", err)214270 }215- if manifest.Format != containerFormat || manifest.Framing != containerFraming || manifest.Volume == "" {271+ if manifest.Format != containerFormat || manifest.Framing != containerFraming {216272 return bootstrap{}, fmt.Errorf("unsupported Atlas container identity or framing")217273 }274+ if err := ValidSlug(manifest.Volume); err != nil {275+ return bootstrap{}, fmt.Errorf("volume identity: %w", err)276+ }277+ if err := manifest.Release.Validate(); err != nil {278+ return bootstrap{}, fmt.Errorf("release: %w", err)279+ }280+ stamp := manifest.Release.Stamp281+ manifest.Release.Stamp = ""282+ stampInput, err := json.Marshal(manifest)283+ if err != nil {284+ return bootstrap{}, fmt.Errorf("marshal stamp input: %w", err)285+ }286+ digest := sha256.Sum256(stampInput)287+ if hex.EncodeToString(digest[:]) != stamp {288+ return bootstrap{}, fmt.Errorf("release stamp does not match bundle contents")289+ }290+ manifest.Release.Stamp = stamp218291 return manifest, nil219292 }220293 @@ -300,6 +373,16 @@ func (reader *Reader) Table(name string) (Table, error) {
300373 return block.Table, nil301374 }302375 376+// TableBlock returns the verified physical bytes for browser or range-based377+// consumers that decode columns themselves.378+func (reader *Reader) TableBlock(name string) ([]byte, error) {379+ reference, held := reader.tables[name]380+ if !held {381+ return nil, fmt.Errorf("bundle has no table %s", name)382+ }383+ return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash)384+}385+303386 func tableHasColumn(table Table, fieldID ID) bool {304387 for _, column := range table.Columns {305388 if column.Field.ID == fieldID {@@ -309,7 +392,7 @@ func tableHasColumn(table Table, fieldID ID) bool {
309392 return false310393 }311394 312-func (reader *Reader) blob(name string) ([]byte, error) {395+func (reader *Reader) Blob(name string) ([]byte, error) {313396 reference, held := reader.blobs[name]314397 if !held {315398 return nil, fmt.Errorf("bundle has no blob %s", name)@@ -317,6 +400,43 @@ func (reader *Reader) blob(name string) ([]byte, error) {
317400 return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash)318401 }319402 403+func (reader *Reader) TableNames() []string {404+ names := make([]string, 0, len(reader.tables))405+ for name := range reader.tables {406+ names = append(names, name)407+ }408+ slices.Sort(names)409+ return names410+}411+412+func (reader *Reader) BlobNames() []string {413+ names := make([]string, 0, len(reader.blobs))414+ for name := range reader.blobs {415+ names = append(names, name)416+ }417+ slices.Sort(names)418+ return names419+}420+421+// Validate reads and verifies every typed table and opaque blob, then restores422+// the semantic root. Installers call it before and after committing a file.423+func (reader *Reader) Validate() error {424+ for name := range reader.tables {425+ if _, err := reader.Table(name); err != nil {426+ return fmt.Errorf("validate %s: %w", name, err)427+ }428+ }429+ for name := range reader.blobs {430+ if _, err := reader.Blob(name); err != nil {431+ return fmt.Errorf("validate %s: %w", name, err)432+ }433+ }434+ if _, err := reader.Volume(); err != nil {435+ return fmt.Errorf("restore volume: %w", err)436+ }437+ return nil438+}439+320440 func readLimitedEntry(entries map[string]*zip.File, name string, maximum int64) ([]byte, error) {321441 entry := entries[name]322442 if entry == nil || int64(entry.UncompressedSize64) > maximum {@@ -13,6 +13,8 @@ func TestBundleIsDeterministicAndKeepsPackedDataStored(t *testing.T) {
1313 if err != nil {1414 t.Fatalf("compile: %v", err)1515 }16+ bundle.Release.CreatedAt = "2026-08-01T20:13:08Z"17+ bundle.Release.Revision = 41618 var first, second bytes.Buffer1719 if err := Write(&first, bundle); err != nil {1820 t.Fatalf("write first: %v", err)@@ -23,6 +25,16 @@ func TestBundleIsDeterministicAndKeepsPackedDataStored(t *testing.T) {
2325 if !bytes.Equal(first.Bytes(), second.Bytes()) {2426 t.Fatal("same semantic volume produced different archive bytes")2527 }28+ opened, err := Open(bytes.NewReader(first.Bytes()), int64(first.Len()), StandardSchema())29+ if err != nil {30+ t.Fatalf("open release: %v", err)31+ }32+ if opened.Release.Title != "Minimal" || opened.Release.CreatedAt != "2026-08-01T20:13:08Z" || opened.Release.Revision != 4 {33+ t.Fatalf("release metadata = %#v", opened.Release)34+ }35+ if len(opened.Release.Stamp) != 64 {36+ t.Fatalf("release stamp = %q, want sha256", opened.Release.Stamp)37+ }2638 2739 archive, err := zip.NewReader(bytes.NewReader(first.Bytes()), int64(first.Len()))2840 if err != nil {@@ -45,6 +57,7 @@ func TestNewerApplicationReadsOlderFileSchema(t *testing.T) {
4557 if err != nil {4658 t.Fatalf("compile: %v", err)4759 }60+ bundle.Release.CreatedAt = "2026-08-01T20:13:08Z"4861 var archive bytes.Buffer4962 if err := Write(&archive, bundle); err != nil {5063 t.Fatalf("write: %v", err)@@ -374,7 +374,7 @@ func decodeAssets(reader *Reader, table Table) ([]Asset, error) {
374374 assets := make([]Asset, table.Rows)375375 for row := 0; row < table.Rows; row++ {376376 path := requiredString(table, row, "asset.path")377- data, err := reader.blob(path)377+ data, err := reader.Blob(path)378378 if err != nil {379379 return nil, err380380 }@@ -100,7 +100,8 @@ type v3Link struct {
100100 }101101 102102 // ImportV3 turns the frozen document-shaped format into the vNext semantic103-// graph. The old format remains a read-only ingress boundary after cutover.103+// graph for migration tooling and old test fixtures. Production scanners and104+// installers reject v3 files after the hard cutover.104105 func ImportV3(reader *bundle.Reader) (Bundle, error) {105106 if err := reader.Validate(); err != nil {106107 return Bundle{}, fmt.Errorf("validate v3 bundle: %w", err)@@ -118,6 +119,8 @@ func ImportV3(reader *bundle.Reader) (Bundle, error) {
118119 if err != nil {119120 return Bundle{}, fmt.Errorf("compile imported v3 bundle: %w", err)120121 }122+ compiled.Release.CreatedAt = reader.Manifest.Version.CreatedAt123+ compiled.Release.Revision = reader.Manifest.Version.Revision121124 for _, name := range reader.Names() {122125 if !strings.HasPrefix(name, bundle.TilesPrefix) {123126 continue@@ -1,125 +0,0 @@
1-package vnext2-3-import (4- "bytes"5- "path/filepath"6- "runtime"7- "testing"8-9- "github.com/FelineStateMachine/atlas/format/bundle"10-)11-12-func TestImportV3TurnsTheIncludedEarthIntoTheVNextGraph(t *testing.T) {13- t.Parallel()14-15- legacy, err := bundle.Open(includedEarth(t))16- if err != nil {17- t.Fatalf("open v3 Earth: %v", err)18- }19- defer legacy.Close()20-21- imported, err := ImportV3(legacy)22- if err != nil {23- t.Fatalf("import v3 Earth: %v", err)24- }25- var archive bytes.Buffer26- if err := Write(&archive, imported); err != nil {27- t.Fatalf("write imported Earth: %v", err)28- }29- reader, err := Open(bytes.NewReader(archive.Bytes()), int64(archive.Len()), StandardSchema())30- if err != nil {31- t.Fatalf("open imported Earth: %v", err)32- }33- volume, err := reader.Volume()34- if err != nil {35- t.Fatalf("restore imported Earth: %v", err)36- }37-38- if volume.ID != "earth" || len(volume.Worlds) != 1 {39- t.Fatalf("imported volume identity = %s, worlds = %d", volume.ID, len(volume.Worlds))40- }41- world := volume.Worlds[0]42- if world.CoordinateSpace.SourceZoom != 5 || world.CoordinateSpace.Size != 8192 {43- t.Fatalf("coordinate space = %#v", world.CoordinateSpace)44- }45- if len(world.RasterPyramids) != 1 || len(world.RasterPyramids[0].Formats) != 7 {46- t.Fatalf("raster pyramids = %#v", world.RasterPyramids)47- }48- if got, want := featureCount(world), 379; got != want {49- t.Fatalf("features = %d, want %d", got, want)50- }51- if len(world.Presentation.Layers) != len(world.FeatureSets) || len(world.Presentation.Legend) != len(world.FeatureSets) {52- t.Fatalf("presentation did not split every v3 collection: %#v", world.Presentation)53- }54-55- countries := featureSetByTitle(world, "Countries")56- if countries == nil || len(countries.Features) == 0 {57- t.Fatal("Countries feature set was not imported")58- }59- fiji := featureByTitle(*countries, "Fiji")60- if fiji == nil || fiji.Geometry.Kind != GeometryPolygon || len(fiji.Geometry.Parts) != 3 {61- t.Fatalf("Fiji geometry = %#v", fiji)62- }63- if len(imported.Blobs) < 1000 {64- t.Fatalf("only %d raster/asset blobs crossed the import", len(imported.Blobs))65- }66-}67-68-func TestImportV3VolumeStopsAtTheSemanticBoundary(t *testing.T) {69- t.Parallel()70-71- legacy, err := bundle.Open(includedEarth(t))72- if err != nil {73- t.Fatalf("open v3 Earth: %v", err)74- }75- defer legacy.Close()76-77- volume, err := ImportV3Volume(legacy.Manifest, legacy.ReadEntry)78- if err != nil {79- t.Fatalf("import semantic Earth: %v", err)80- }81- if volume.ID != "earth" || len(volume.Worlds) != 1 {82- t.Fatalf("semantic volume = %#v", volume)83- }84- if got, want := featureCount(volume.Worlds[0]), 379; got != want {85- t.Fatalf("semantic features = %d, want %d", got, want)86- }87- if len(volume.Assets) != 0 {88- t.Fatalf("semantic-only import loaded %d assets", len(volume.Assets))89- }90-}91-92-func includedEarth(t *testing.T) string {93- t.Helper()94- _, file, _, ok := runtime.Caller(0)95- if !ok {96- t.Fatal("locate importer test")97- }98- return filepath.Join(filepath.Dir(file), "..", "..", "included", "earth-20260803-76633cb323ec.atlas")99-}100-101-func featureCount(world World) int {102- total := 0103- for _, set := range world.FeatureSets {104- total += len(set.Features)105- }106- return total107-}108-109-func featureSetByTitle(world World, title string) *FeatureSet {110- for index := range world.FeatureSets {111- if world.FeatureSets[index].Title == title {112- return &world.FeatureSets[index]113- }114- }115- return nil116-}117-118-func featureByTitle(set FeatureSet, title string) *Feature {119- for index := range set.Features {120- if set.Features[index].Title == title {121- return &set.Features[index]122- }123- }124- return nil125-}@@ -0,0 +1,190 @@
1+package vnext2+3+import (4+ "bytes"5+ "fmt"6+ "io"7+ "os"8+ "path/filepath"9+ "sort"10+)11+12+type Skipped struct {13+ Locator string14+ Err error15+}16+17+func (skipped Skipped) Error() string { return skipped.Locator + ": " + skipped.Err.Error() }18+func (skipped Skipped) Unwrap() error { return skipped.Err }19+20+// File owns the operating-system handle behind a Reader.21+type File struct {22+ *Reader23+ source *os.File24+ size int6425+ path string26+}27+28+func OpenFile(path string, applicationSchema Schema) (*File, error) {29+ source, err := os.Open(path)30+ if err != nil {31+ return nil, fmt.Errorf("open %s: %w", filepath.Base(path), err)32+ }33+ stat, err := source.Stat()34+ if err != nil {35+ source.Close()36+ return nil, fmt.Errorf("stat %s: %w", filepath.Base(path), err)37+ }38+ reader, err := Open(source, stat.Size(), applicationSchema)39+ if err != nil {40+ source.Close()41+ return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)42+ }43+ return &File{Reader: reader, source: source, size: stat.Size(), path: path}, nil44+}45+46+func (file *File) Close() error { return file.source.Close() }47+48+func (file *File) Descriptor() Descriptor {49+ return DescriptorOf(file.path, file.VolumeID, file.Release, file.size)50+}51+52+func Describe(path string, applicationSchema Schema) (Descriptor, error) {53+ file, err := OpenFile(path, applicationSchema)54+ if err != nil {55+ return Descriptor{}, err56+ }57+ defer file.Close()58+ return file.Descriptor(), nil59+}60+61+func Scan(dir string, applicationSchema Schema) ([]Descriptor, []Skipped, error) {62+ paths, err := filepath.Glob(filepath.Join(dir, "*"+Extension))63+ if err != nil {64+ return nil, nil, fmt.Errorf("scan %s: %w", dir, err)65+ }66+ sort.Strings(paths)67+ descriptors := make([]Descriptor, 0, len(paths))68+ var skipped []Skipped69+ for _, path := range paths {70+ descriptor, err := Describe(path, applicationSchema)71+ if err != nil {72+ skipped = append(skipped, Skipped{Locator: path, Err: err})73+ continue74+ }75+ descriptors = append(descriptors, descriptor)76+ }77+ return descriptors, skipped, nil78+}79+80+func Install(dir, source string, applicationSchema Schema) (Descriptor, error) {81+ opened, err := OpenFile(source, applicationSchema)82+ if err != nil {83+ return Descriptor{}, err84+ }85+ if err := opened.Validate(); err != nil {86+ opened.Close()87+ return Descriptor{}, fmt.Errorf("%s: %w", filepath.Base(source), err)88+ }89+ descriptor := opened.Descriptor()90+ if err := opened.Close(); err != nil {91+ return Descriptor{}, fmt.Errorf("close %s: %w", filepath.Base(source), err)92+ }93+ if err := os.MkdirAll(dir, 0o755); err != nil {94+ return Descriptor{}, fmt.Errorf("create library: %w", err)95+ }96+ target := filepath.Join(dir, VersionedFileName(descriptor.Slug, descriptor.release()))97+ if sameFile(source, target) {98+ return Describe(target, applicationSchema)99+ }100+ if _, err := os.Stat(target); err == nil {101+ return Describe(target, applicationSchema)102+ }103+ if err := copyInto(dir, source, target); err != nil {104+ return Descriptor{}, err105+ }106+ return validateInstalled(target, applicationSchema)107+}108+109+func InstallBytes(dir string, source []byte, applicationSchema Schema) (Descriptor, error) {110+ opened, err := Open(bytes.NewReader(source), int64(len(source)), applicationSchema)111+ if err != nil {112+ return Descriptor{}, err113+ }114+ if err := opened.Validate(); err != nil {115+ return Descriptor{}, err116+ }117+ descriptor := DescriptorOf("", opened.VolumeID, opened.Release, int64(len(source)))118+ if err := os.MkdirAll(dir, 0o755); err != nil {119+ return Descriptor{}, fmt.Errorf("create library: %w", err)120+ }121+ target := filepath.Join(dir, VersionedFileName(descriptor.Slug, descriptor.release()))122+ if _, err := os.Stat(target); err == nil {123+ return Describe(target, applicationSchema)124+ }125+ if err := stageInto(dir, bytes.NewReader(source), target); err != nil {126+ return Descriptor{}, err127+ }128+ return validateInstalled(target, applicationSchema)129+}130+131+func validateInstalled(target string, applicationSchema Schema) (Descriptor, error) {132+ installed, err := OpenFile(target, applicationSchema)133+ if err != nil {134+ return Descriptor{}, fmt.Errorf("installed %s: %w", filepath.Base(target), err)135+ }136+ defer installed.Close()137+ if err := installed.Validate(); err != nil {138+ return Descriptor{}, fmt.Errorf("installed %s: %w", filepath.Base(target), err)139+ }140+ return installed.Descriptor(), nil141+}142+143+func copyInto(dir, source, target string) error {144+ from, err := os.Open(source)145+ if err != nil {146+ return fmt.Errorf("open %s: %w", filepath.Base(source), err)147+ }148+ defer from.Close()149+ return stageInto(dir, from, target)150+}151+152+func stageInto(dir string, source io.Reader, target string) error {153+ staged, err := os.CreateTemp(dir, ".installing-*")154+ if err != nil {155+ return fmt.Errorf("stage %s: %w", filepath.Base(target), err)156+ }157+ stagedName := staged.Name()158+ defer os.Remove(stagedName)159+ if _, err := io.Copy(staged, source); err != nil {160+ staged.Close()161+ return fmt.Errorf("stage %s: %w", filepath.Base(target), err)162+ }163+ if err := staged.Sync(); err != nil {164+ staged.Close()165+ return fmt.Errorf("sync %s: %w", filepath.Base(target), err)166+ }167+ if err := staged.Close(); err != nil {168+ return fmt.Errorf("close %s: %w", filepath.Base(target), err)169+ }170+ if err := os.Chmod(stagedName, 0o644); err != nil {171+ return fmt.Errorf("mode %s: %w", filepath.Base(target), err)172+ }173+ if err := os.Rename(stagedName, target); err != nil {174+ return fmt.Errorf("install %s: %w", filepath.Base(target), err)175+ }176+ return nil177+}178+179+func sameFile(left, right string) bool {180+ leftInfo, leftErr := os.Stat(left)181+ rightInfo, rightErr := os.Stat(right)182+ return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)183+}184+185+func (descriptor Descriptor) release() Release {186+ return Release{187+ Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,188+ Stamp: descriptor.Stamp, Worlds: descriptor.Worlds,189+ }190+}@@ -0,0 +1,68 @@
1+package vnext2+3+import (4+ "bytes"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+func TestInstallStagesValidatedVNextBundleAndScanRejectsLegacy(t *testing.T) {12+ t.Parallel()13+14+ packed, err := Compile(minimalVolume())15+ if err != nil {16+ t.Fatalf("compile: %v", err)17+ }18+ packed.Release.CreatedAt = "2026-08-01T20:13:08Z"19+ var data bytes.Buffer20+ if err := Write(&data, packed); err != nil {21+ t.Fatalf("write: %v", err)22+ }23+24+ source := filepath.Join(t.TempDir(), "download.atlas")25+ if err := os.WriteFile(source, data.Bytes(), 0o644); err != nil {26+ t.Fatalf("source: %v", err)27+ }28+ library := t.TempDir()29+ installed, err := Install(library, source, StandardSchema())30+ if err != nil {31+ t.Fatalf("install: %v", err)32+ }33+ if filepath.Base(installed.Locator) != VersionedFileName("minimal", installed.release()) {34+ t.Fatalf("installed as %s", installed.Locator)35+ }36+37+ legacy := filepath.Join(library, "legacy.atlas")38+ if err := os.WriteFile(legacy, []byte("not-vnext"), 0o644); err != nil {39+ t.Fatalf("legacy: %v", err)40+ }41+ descriptors, skipped, err := Scan(library, StandardSchema())42+ if err != nil {43+ t.Fatalf("scan: %v", err)44+ }45+ if len(descriptors) != 1 || len(skipped) != 1 || !strings.Contains(skipped[0].Err.Error(), "open Atlas archive") {46+ t.Fatalf("scan = %#v, skipped = %#v", descriptors, skipped)47+ }48+}49+50+func TestInstallRefusesCorruptionBeforeLibraryWrite(t *testing.T) {51+ t.Parallel()52+53+ library := t.TempDir()54+ source := filepath.Join(t.TempDir(), "broken.atlas")55+ if err := os.WriteFile(source, []byte("broken"), 0o644); err != nil {56+ t.Fatalf("source: %v", err)57+ }58+ if _, err := Install(library, source, StandardSchema()); err == nil {59+ t.Fatal("broken source was installed")60+ }61+ entries, err := os.ReadDir(library)62+ if err != nil {63+ t.Fatalf("read library: %v", err)64+ }65+ if len(entries) != 0 {66+ t.Fatalf("broken install left %d files", len(entries))67+ }68+}@@ -0,0 +1,165 @@
1+package vnext2+3+import (4+ "encoding/hex"5+ "fmt"6+ "sort"7+ "strings"8+ "time"9+)10+11+const Extension = ".atlas"12+13+// Release is the small, schema-independent identity used while scanning a14+// library. CreatedAt is source capture time, not build time.15+type Release struct {16+ Title string `json:"title"`17+ CreatedAt string `json:"createdAt"`18+ Revision int `json:"revision,omitempty"`19+ Stamp string `json:"stamp"`20+ Worlds int `json:"worlds"`21+}22+23+func (release Release) Validate() error { return release.validate(true) }24+25+func (release Release) validate(requireStamp bool) error {26+ if release.Title == "" {27+ return fmt.Errorf("title is empty")28+ }29+ if _, err := time.Parse(time.RFC3339, release.CreatedAt); err != nil {30+ return fmt.Errorf("createdAt is not RFC 3339: %w", err)31+ }32+ if release.Revision < 0 {33+ return fmt.Errorf("revision is negative")34+ }35+ if release.Worlds < 1 {36+ return fmt.Errorf("world count is less than one")37+ }38+ if requireStamp {39+ decoded, err := hex.DecodeString(release.Stamp)40+ if err != nil || len(decoded) != 32 {41+ return fmt.Errorf("stamp is not a SHA-256 digest")42+ }43+ }44+ return nil45+}46+47+// ValidSlug keeps file and URL identities deliberately smaller than paths.48+func ValidSlug(slug string) error {49+ if slug == "" {50+ return fmt.Errorf("empty")51+ }52+ for _, character := range slug {53+ if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '_' {54+ continue55+ }56+ return fmt.Errorf("%q contains %q", slug, character)57+ }58+ if strings.HasPrefix(slug, "-") || strings.HasPrefix(slug, "_") {59+ return fmt.Errorf("%q starts with a separator", slug)60+ }61+ return nil62+}63+64+type Descriptor struct {65+ Locator string66+ Slug string67+ Title string68+ Stamp string69+ CreatedAt string70+ Revision int71+ Size int6472+ Worlds int73+}74+75+func DescriptorOf(locator, slug string, release Release, size int64) Descriptor {76+ return Descriptor{77+ Locator: locator, Slug: slug, Title: release.Title, Stamp: release.Stamp,78+ CreatedAt: release.CreatedAt, Revision: release.Revision, Size: size, Worlds: release.Worlds,79+ }80+}81+82+func Newer(left, right Descriptor) bool {83+ if left.CreatedAt != right.CreatedAt {84+ return left.CreatedAt > right.CreatedAt85+ }86+ if left.Revision != right.Revision {87+ return left.Revision > right.Revision88+ }89+ if left.Stamp != right.Stamp {90+ return left.Stamp > right.Stamp91+ }92+ return left.Locator > right.Locator93+}94+95+func Fold(candidates []Descriptor) map[string]Descriptor {96+ winners := make(map[string]Descriptor, len(candidates))97+ for _, candidate := range candidates {98+ standing, held := winners[candidate.Slug]99+ if !held || Newer(candidate, standing) {100+ winners[candidate.Slug] = candidate101+ }102+ }103+ return winners104+}105+106+func Shadowed(candidates []Descriptor) []Descriptor {107+ winners := Fold(candidates)108+ shadowed := make([]Descriptor, 0)109+ for _, candidate := range candidates {110+ if winner := winners[candidate.Slug]; winner.Locator != candidate.Locator {111+ shadowed = append(shadowed, candidate)112+ }113+ }114+ sort.Slice(shadowed, func(i, j int) bool {115+ if shadowed[i].Slug != shadowed[j].Slug {116+ return shadowed[i].Slug < shadowed[j].Slug117+ }118+ return Newer(shadowed[i], shadowed[j])119+ })120+ return shadowed121+}122+123+func Changed(before, after map[string]Descriptor) []string {124+ changed := make([]string, 0)125+ for slug, winner := range after {126+ if standing, held := before[slug]; !held || standing.Stamp != winner.Stamp {127+ changed = append(changed, slug)128+ }129+ }130+ for slug := range before {131+ if _, held := after[slug]; !held {132+ changed = append(changed, slug)133+ }134+ }135+ sort.Strings(changed)136+ return changed137+}138+139+func ShortStamp(stamp string) string {140+ if len(stamp) > 12 {141+ return stamp[:12]142+ }143+ return stamp144+}145+146+func CaptureDay(createdAt string) string {147+ var day strings.Builder148+ for _, character := range createdAt {149+ if character >= '0' && character <= '9' {150+ day.WriteRune(character)151+ if day.Len() == 8 {152+ break153+ }154+ }155+ }156+ return day.String()157+}158+159+func VersionedFileName(slug string, release Release) string {160+ name := slug161+ if day := CaptureDay(release.CreatedAt); day != "" {162+ name += "-" + day163+ }164+ return name + "-" + ShortStamp(release.Stamp) + Extension165+}@@ -0,0 +1,45 @@
1+package vnext2+3+import "testing"4+5+func TestReleaseValidation(t *testing.T) {6+ t.Parallel()7+8+ valid := Release{9+ Title: "Minimal",10+ CreatedAt: "2026-08-01T20:13:08Z",11+ Revision: 1,12+ Stamp: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",13+ Worlds: 1,14+ }15+ for name, mutate := range map[string]func(*Release){16+ "title": func(release *Release) { release.Title = "" },17+ "capture": func(release *Release) { release.CreatedAt = "yesterday" },18+ "revision": func(release *Release) { release.Revision = -1 },19+ "stamp": func(release *Release) { release.Stamp = "short" },20+ "world count": func(release *Release) { release.Worlds = 0 },21+ } {22+ t.Run(name, func(t *testing.T) {23+ candidate := valid24+ mutate(&candidate)25+ if err := candidate.Validate(); err == nil {26+ t.Fatal("invalid release was accepted")27+ }28+ })29+ }30+}31+32+func TestFoldIsDeterministicAndRevisionAware(t *testing.T) {33+ t.Parallel()34+35+ older := Descriptor{Locator: "older.atlas", Slug: "minimal", CreatedAt: "2026-08-01T20:13:08Z", Revision: 1, Stamp: "a"}36+ rebuilt := Descriptor{Locator: "rebuilt.atlas", Slug: "minimal", CreatedAt: older.CreatedAt, Revision: 2, Stamp: "b"}37+ newer := Descriptor{Locator: "newer.atlas", Slug: "minimal", CreatedAt: "2026-08-02T20:13:08Z", Revision: 0, Stamp: "c"}38+39+ if winner := Fold([]Descriptor{newer, rebuilt, older})["minimal"]; winner != newer {40+ t.Fatalf("winner = %#v, want newest capture", winner)41+ }42+ if winner := Fold([]Descriptor{rebuilt, older})["minimal"]; winner != rebuilt {43+ t.Fatalf("winner = %#v, want revised build", winner)44+ }45+}@@ -66,6 +66,7 @@ func TestVolumeTravelsFromDomainThroughBundleToPresentation(t *testing.T) {
6666 if err != nil {6767 t.Fatalf("compile volume: %v", err)6868 }69+ packed.Release.CreatedAt = "2026-08-01T20:13:08Z"6970 var archive bytes.Buffer7071 if err := Write(&archive, packed); err != nil {7172 t.Fatalf("write bundle: %v", err)@@ -1,7 +1,7 @@
11 # The included Earth volume22 33 The one `.atlas` file in this directory is the included Earth volume: a real,4-ordinary format-v3 bundle the desktop shell embeds (`//go:embed included/*.atlas`4+ordinary native schema bundle the desktop shell embeds (`//go:embed included/*.atlas`55 in `main.go`) and installs into the reader's library at startup, through the66 same `format/bundle` path an imported bundle takes. A first launch therefore77 opens onto a world instead of an empty library, and everything after@@ -39,7 +39,7 @@ implied; the bundle's provenance ledger carries the credit, and nothing here
3939 does the other two. Natural Earth is in the public domain; its boundaries and4040 its primary-capital designations are carried exactly as that project draws4141 them, de facto, with no editorial judgement added here. No source URL appears42-inside the bundle — format v3's runtime-URL prohibition holds for this volume42+inside the bundle — the native format's offline invariant holds for this volume4343 exactly as for any other.4444 4545 ## Derivation@@ -58,7 +58,7 @@ past the world-pixel resolution, the way every deep pyramid in the corpus is.
5858 The ordinary tile lane folds the reference level and everything shallower down5959 from the cut and derives coverage, bounds and stamps; the ordinary compose lane6060 writes and validates the bundle. The full extent survives uncropped, tiles are61-stored, not deflated, as format v3 requires, and the finished file is ~21 MiB —61+stored, not deflated, as the native format requires, and the finished file is ~21 MiB —6262 inside the 25 MiB budget the included volume keeps so the executable stays6363 portable.6464 Binary file.
@@ -135,10 +135,10 @@ func (a *App) library() library {
135135 bySlug: make(map[string]hostenv.Volume, len(volumes)),136136 }137137 sort.SliceStable(held.order, func(i, j int) bool {138- return held.order[i].Manifest().Volume.Title < held.order[j].Manifest().Volume.Title138+ return held.order[i].Info().Title < held.order[j].Info().Title139139 })140140 for _, volume := range volumes {141- held.bySlug[volume.Manifest().Volume.Slug] = volume141+ held.bySlug[volume.Info().Slug] = volume142142 }143143 return held144144 }@@ -1,7 +1,6 @@
11 package app_test22 33 import (4- "bytes"54 "context"65 "encoding/json"76 "errors"@@ -13,6 +12,7 @@ import (
1312 "testing"1413 1514 "github.com/FelineStateMachine/atlas/format/bundle"15+ "github.com/FelineStateMachine/atlas/format/vnext"1616 "github.com/FelineStateMachine/atlas/internal/app"1717 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1818 )@@ -26,16 +26,35 @@ type fakeVolume struct {
2626 entries map[string][]byte2727 }2828 29-func (v *fakeVolume) Manifest() bundle.Manifest { return v.manifest }29+func (v *fakeVolume) Info() hostenv.VolumeInfo {30+ manifest := v.manifest31+ info := hostenv.VolumeInfo{32+ Slug: manifest.Volume.Slug, Title: manifest.Volume.Title, Stamp: manifest.Version.Stamp,33+ Release: vnext.Release{Title: manifest.Volume.Title, CreatedAt: manifest.Version.CreatedAt, Revision: manifest.Version.Revision, Stamp: manifest.Version.Stamp, Worlds: len(manifest.Worlds)},34+ TileGrid: hostenv.TileGrid{SourceZoom: manifest.TileGrid.SourceZoom, FirstTile: manifest.TileGrid.FirstTile, TileSize: manifest.TileGrid.TileSize, Size: manifest.TileGrid.Size},35+ }36+ for _, world := range manifest.Worlds {37+ info.Worlds = append(info.Worlds, hostenv.WorldInfo{Slug: world.Slug, Title: world.Title, Parent: world.Parent, Points: world.Points, Paths: world.Paths, Areas: world.Areas, UpdatedAt: world.UpdatedAt})38+ }39+ return info40+}3041 31-func (v *fakeVolume) Open(name string) (io.ReadCloser, int64, error) {42+func (v *fakeVolume) Semantic() vnext.Volume {43+ semantic, _ := vnext.ImportV3Volume(v.manifest, func(name string) ([]byte, error) { return v.Blob(name) })44+ return semantic45+}46+47+func (v *fakeVolume) Blob(name string) ([]byte, error) {3248 held, ok := v.entries[name]3349 if !ok {34- return nil, 0, errors.New("no such entry")50+ return nil, errors.New("no such entry")3551 }36- return io.NopCloser(bytes.NewReader(held)), int64(len(held)), nil52+ return held, nil3753 }3854 55+func (v *fakeVolume) Schema() ([]byte, error) { return vnext.StandardSchema().Canonical() }56+func (v *fakeVolume) TableBlock(string) ([]byte, error) { return nil, errors.New("no typed fixture") }57+3958 type fakeVolumes struct {4059 volumes []hostenv.Volume4160 location string@@ -66,14 +85,14 @@ func (s *fakeVolumes) Install(name string, content io.Reader) (hostenv.Installed
6685 if s.arriving == nil {6786 return hostenv.Installed{}, errors.New("nothing to install")6887 }69- manifest := s.arriving.Manifest()88+ info := s.arriving.Info()7089 s.volumes = append(s.volumes, s.arriving)7190 return hostenv.Installed{72- Slug: manifest.Volume.Slug,73- Title: manifest.Volume.Title,74- Stamp: manifest.Version.Stamp,91+ Slug: info.Slug,92+ Title: info.Title,93+ Stamp: info.Stamp,7594 Already: s.already,76- Changed: []string{manifest.Volume.Slug},95+ Changed: []string{info.Slug},7796 }, nil7897 }7998 @@ -4,6 +4,7 @@
44 55 html,66 body {7+ --wails-drop-target: drop;78 width: 100%;89 height: 100%;910 margin: 0;@@ -28,4 +29,3 @@ input {
2829 -webkit-user-select: text;2930 user-select: text;3031 }31-@@ -2,7 +2,6 @@ package app
22 33 import (44 "encoding/json"5- "io"65 "log/slog"76 "net/http"87 "sort"@@ -10,6 +9,7 @@ import (
109 "strings"1110 1211 "github.com/FelineStateMachine/atlas/format/bundle"12+ "github.com/FelineStateMachine/atlas/format/vnext"1313 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1414 "github.com/FelineStateMachine/atlas/internal/logging"1515 )@@ -34,6 +34,7 @@ var contentTypes = map[string]string{
3434 ".bin": "application/octet-stream",3535 ".jpg": "image/jpeg",3636 ".png": "image/png",37+ ".pack": "application/vnd.atlas.table",3738 ".webp": "image/webp",3839 ".svg": "image/svg+xml",3940 }@@ -49,8 +50,8 @@ type catalogVolume struct {
4950 Title string `json:"title"`5051 Stamp string `json:"stamp"`5152 Base string `json:"base"`52- TileGrid bundle.TileGrid `json:"tileGrid"`53- Worlds []bundle.WorldEntry `json:"worlds"`53+ TileGrid hostenv.TileGrid `json:"tileGrid"`54+ Worlds []hostenv.WorldInfo `json:"worlds"`5455 }5556 5657 // catalogDoc is the whole catalog. BundlesDir rides along so an empty library@@ -82,14 +83,10 @@ func (a *App) handleCatalog(w http.ResponseWriter, r *http.Request) {
8283 func composeCatalog(volumes []hostenv.Volume, location string) ([]byte, error) {8384 listed := make([]catalogVolume, 0, len(volumes))8485 for _, volume := range volumes {85- manifest := volume.Manifest()86+ info := volume.Info()8687 listed = append(listed, catalogVolume{87- Slug: manifest.Volume.Slug,88- Title: manifest.Volume.Title,89- Stamp: manifest.Version.Stamp,90- Base: volumeBase(manifest),91- TileGrid: manifest.TileGrid,92- Worlds: manifest.Worlds,88+ Slug: info.Slug, Title: info.Title, Stamp: info.Stamp,89+ Base: volumeBase(info), TileGrid: info.TileGrid, Worlds: info.Worlds,9390 })9491 }9592 sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })@@ -97,8 +94,8 @@ func composeCatalog(volumes []hostenv.Volume, location string) ([]byte, error) {
9794 }9895 9996 // volumeBase is where one build's content is served from.100-func volumeBase(m bundle.Manifest) string {101- return BasePath + "/" + m.Volume.Slug + "/" + bundle.ShortStamp(m.Version.Stamp)97+func volumeBase(info hostenv.VolumeInfo) string {98+ return BasePath + "/" + info.Slug + "/" + vnext.ShortStamp(info.Stamp)10299 }103100 104101 // handleContent serves one entry out of one build.@@ -121,13 +118,31 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
121118 http.NotFound(w, r)122119 return123120 }124- manifest := held.Manifest()125- if r.PathValue("stamp") != bundle.ShortStamp(manifest.Version.Stamp) {121+ info := held.Info()122+ if r.PathValue("stamp") != vnext.ShortStamp(info.Stamp) {126123 http.NotFound(w, r)127124 return128125 }129126 130127 rest := r.PathValue("rest")128+ if rest == "schema.json" {129+ data, err := held.Schema()130+ if err != nil {131+ http.NotFound(w, r)132+ return133+ }134+ serveProjectedContent(w, data, "application/schema+json")135+ return136+ }137+ if strings.HasPrefix(rest, "data/") && strings.HasSuffix(rest, ".pack") {138+ data, err := held.TableBlock(rest)139+ if err != nil {140+ http.NotFound(w, r)141+ return142+ }143+ serveProjectedContent(w, data, contentTypes[".pack"])144+ return145+ }131146 if strings.HasPrefix(rest, bundle.WorldsPrefix) {132147 data, kind, projected, err := a.projectedContent(held, rest)133148 if err != nil || !projected {@@ -153,20 +168,19 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
153168 return154169 }155170 156- entry, size, err := held.Open(rest)171+ data, err := held.Blob(rest)157172 if err != nil {158173 http.NotFound(w, r)159174 return160175 }161- defer entry.Close()162176 w.Header().Set("Content-Type", kind)163- w.Header().Set("Content-Length", strconv.FormatInt(size, 10))177+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))164178 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")165- if _, err := io.Copy(w, entry); err != nil {179+ if _, err := w.Write(data); err != nil {166180 // The response is already on the wire; there is nothing to say to the167181 // client, and a reader who navigated away is the usual cause.168182 slog.Debug("content cut short", logging.Op("serve"),169- logging.Volume(manifest.Volume.Slug), logging.Path(rest), slog.Any("error", err))183+ logging.Volume(info.Slug), logging.Path(rest), slog.Any("error", err))170184 }171185 }172186 @@ -129,10 +129,10 @@ func (a *App) announce(changed []string) {
129129 if !serving {130130 continue131131 }132- manifest := volume.Manifest()132+ info := volume.Info()133133 world := a.session(slug).World134- if _, ok := worldEntry(manifest, world); !ok {135- world = manifest.Worlds[0].Slug134+ if _, ok := worldEntry(info, world); !ok {135+ world = info.Worlds[0].Slug136136 }137137 where := partialTargets["shell"]138138 directive := []byte("<hx-partial hx-target=\"" + where.target + "\" hx-swap=\"" + where.swap +@@ -22,7 +22,7 @@ import (
2222 "errors"2323 "io"2424 25- "github.com/FelineStateMachine/atlas/format/bundle"25+ "github.com/FelineStateMachine/atlas/format/vnext"2626 )2727 2828 // ErrNoSession is what a [SessionStore] returns for a record it does not@@ -62,11 +62,11 @@ var ErrNoSelection = errors.New("hostenv: nothing was chosen")
6262 // serving build of each volume, a way to open entries out of one, and the two6363 // operations that change what is installed.6464 //65-// The registry model is scan at launch, rescan on import -- no directory66-// watching (issue #5 §2, decision 15). A file dropped into the library from67-// outside appears at the next launch. The fold that decides which build of a68-// volume serves is pure and lives in format/bundle; a store only has to walk69-// whatever it keeps its bundles in and hand the fold its descriptors.65+// The registry model is scan at launch, rescan on install -- no directory66+// watching (issue #5 §2, decision 15). A file copied directly into the library67+// outside Atlas appears at the next launch. Desktop open and drop events use68+// Install instead. The serving-build fold is pure and lives in format/vnext;69+// a store only walks its library and hands the fold its descriptors.7070 type VolumeStore interface {7171 // Volumes lists the serving build of every installed volume, one per7272 // slug, sorted by slug. The result is a snapshot: it does not change@@ -94,13 +94,46 @@ type VolumeStore interface {
9494 // Volume is one serving build: what the bundle says about itself, and a door9595 // to the entries inside it.9696 type Volume interface {97- // Manifest is the volume's own account of itself, already validated.98- Manifest() bundle.Manifest97+ // Info is the small, schema-independent account used by navigation and98+ // catalog views without decoding physical blocks.99+ Info() VolumeInfo99100 100- // Open reads one entry of the archive by its full name --101- // "worlds/hyrule.json", "tiles/hyrule/0/0/0.jpg" -- and reports its102- // uncompressed size, so a server can announce a length before streaming.103- Open(entry string) (io.ReadCloser, int64, error)101+ // Semantic is the canonical graph restored from typed blocks.102+ Semantic() vnext.Volume103+104+ // Blob reads one opaque payload such as a raster tile or image asset.105+ Blob(entry string) ([]byte, error)106+107+ // Schema and TableBlock expose the native typed data plane to clients that108+ // decode columns directly.109+ Schema() ([]byte, error)110+ TableBlock(entry string) ([]byte, error)111+}112+113+type VolumeInfo struct {114+ Slug string `json:"slug"`115+ Title string `json:"title"`116+ Stamp string `json:"stamp"`117+ Release vnext.Release `json:"release"`118+ Worlds []WorldInfo `json:"worlds"`119+ TileGrid TileGrid `json:"tileGrid"`120+}121+122+type WorldInfo struct {123+ Slug string `json:"slug"`124+ Title string `json:"title"`125+ Parent string `json:"parent,omitempty"`126+ Points int `json:"points"`127+ Paths int `json:"paths"`128+ Areas int `json:"areas"`129+ UpdatedAt string `json:"updatedAt"`130+}131+132+type TileGrid struct {133+ SourceZoom int `json:"sourceZoom"`134+ FirstTile int `json:"firstTile"`135+ TileSize int `json:"tileSize"`136+ Size int `json:"size"`104137 }105138 106139 // Installed is what an import turned out to be: the volume it was a build of,@@ -4,12 +4,11 @@ import (
44 "bytes"55 "context"66 "errors"7- "io"87 "os"98 "path/filepath"109 "testing"1110 12- "github.com/FelineStateMachine/atlas/format/bundle"11+ "github.com/FelineStateMachine/atlas/format/vnext"1312 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1413 "github.com/FelineStateMachine/atlas/internal/app/hostenv/oshost"1514 )@@ -29,52 +28,41 @@ func (b build) write(t *testing.T, dir string) string {
2928 if b.world == "" {3029 b.world = "overworld"3130 }32- manifest := bundle.Manifest{33- Format: bundle.Format,34- FormatVersion: bundle.FormatVersion,35- Volume: bundle.Volume{Slug: b.slug, Title: b.title},36- Version: bundle.Version{37- Stamp: bundle.HashBytes([]byte(b.slug + b.createdAt + b.world)),38- CreatedAt: b.createdAt,39- Revision: b.revision,40- },41- TileGrid: bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},42- Worlds: []bundle.WorldEntry{{43- Slug: b.world, Title: b.title + " ground", Points: 1, UpdatedAt: b.createdAt,31+ volume := vnext.Volume{32+ ID: b.slug, Title: b.title,33+ Worlds: []vnext.World{{34+ ID: b.world, Title: b.title + " ground",35+ CoordinateSpace: vnext.CoordinateSpace{ID: "space", Kind: "synthetic", Unit: "pixel", Definition: "atlas:plane", SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192, Extent: [4]float64{0, 0, 8192, 8192}},36+ FeatureSets: []vnext.FeatureSet{{ID: "markers", Title: "Markers", SemanticType: "geometry.point", Features: []vnext.Feature{{37+ ID: "origin", Title: "Origin", Geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{0, 0}}}}}},38+ }}}},39+ RasterPyramids: []vnext.RasterPyramid{{ID: "base", Name: "Base", Codec: "image/jpeg", TileSize: 256, MinZoom: 0, MaxZoom: 0, FullZoom: 0, SourceZoom: 13, Template: "tiles/" + b.world + "/{z}/{x}/{y}.jpg", Formats: []string{"jpg"}}},40+ Presentation: vnext.Presentation{ID: "default", Title: "Default", Styles: []vnext.Style{{ID: "marker"}}, Layers: []vnext.Layer{{ID: "markers", FeatureSet: "markers", Style: "marker", Visible: true}}},4441 }},4542 }46- path := filepath.Join(dir, bundle.VersionedFileName(manifest))47- file, err := os.Create(path)43+ packed, err := vnext.Compile(volume)4844 if err != nil {4945 t.Fatal(err)5046 }51- defer file.Close()52- writer, err := bundle.NewWriter(file, manifest)47+ packed.Release.CreatedAt = b.createdAt48+ packed.Release.Revision = b.revision49+ packed.Blobs = append(packed.Blobs, vnext.Blob{Name: "tiles/" + b.world + "/0/0/0.jpg", Data: []byte("raster")})50+ path := filepath.Join(dir, b.slug+"-"+b.createdAt[5:7]+b.createdAt[8:10]+".atlas")51+ file, err := os.Create(path)5352 if err != nil {5453 t.Fatal(err)5554 }56- detail := `{"lenses":[{"tiles":"` + b.world + `","minZoom":0,"maxZoom":0,"formats":["jpg"]}],` +57- `"collections":[{"id":1,"title":"Marker","kind":"point","visible":true}]}`58- if err := writer.AddDeflated(bundle.WorldEntryName(b.world, bundle.WorldSuffix), []byte(detail)); err != nil {59- t.Fatal(err)60- }61- packed := bundle.PackLocations([]bundle.Location{{ID: 1, Title: "Origin"}})62- if err := writer.AddStored(bundle.WorldEntryName(b.world, bundle.PackedSuffix), bytes.NewReader(packed)); err != nil {63- t.Fatal(err)64- }65- if err := writer.AddDeflated(bundle.WorldEntryName(b.world, bundle.TextSuffix), []byte(`{}`)); err != nil {55+ if err := vnext.Write(file, packed); err != nil {56+ file.Close()6657 t.Fatal(err)6758 }68- if err := writer.AddStored(bundle.TilesPrefix+b.world+"/0/0/0.jpg", bytes.NewReader([]byte("raster"))); err != nil {69- t.Fatal(err)70- }71- if err := writer.Close(); err != nil {59+ if err := file.Close(); err != nil {7260 t.Fatal(err)7361 }7462 return path7563 }7664 77-// The fold is format/bundle's; what this checks is that the walk hands it65+// The fold is format/vnext's; what this checks is that the walk hands it7866 // every build and serves the one it picks.7967 func TestVolumesServesTheFoldsWinner(t *testing.T) {8068 dir := t.TempDir()@@ -93,27 +81,22 @@ func TestVolumesServesTheFoldsWinner(t *testing.T) {
9381 if len(volumes) != 2 {9482 t.Fatalf("%d volumes serving, want 2", len(volumes))9583 }96- if got := volumes[0].Manifest().Volume.Slug; got != "mars" {84+ if got := volumes[0].Info().Slug; got != "mars" {9785 t.Errorf("volumes are listed %q first, want them sorted by slug", got)9886 }99- tunic := volumes[1].Manifest()100- if tunic.Version.CreatedAt != "2026-02-01T00:00:00Z" {101- t.Errorf("serving the build of %s, want the newest capture", tunic.Version.CreatedAt)87+ tunic := volumes[1].Info()88+ if tunic.Release.CreatedAt != "2026-02-01T00:00:00Z" {89+ t.Errorf("serving the build of %s, want the newest capture", tunic.Release.CreatedAt)10290 }10391 104- entry, size, err := volumes[1].Open(bundle.WorldEntryName("overworld", bundle.WorldSuffix))105- if err != nil {106- t.Fatal(err)107- }108- defer entry.Close()109- payload, err := io.ReadAll(entry)92+ payload, err := volumes[1].Blob("tiles/overworld/0/0/0.jpg")11093 if err != nil {11194 t.Fatal(err)11295 }113- if int64(len(payload)) != size {114- t.Errorf("entry announced %d bytes and read %d", size, len(payload))96+ if string(payload) != "raster" {97+ t.Errorf("blob = %q", payload)11598 }116- if _, _, err := volumes[1].Open("worlds/not-a-world.json"); err == nil {99+ if _, err := volumes[1].Blob("worlds/not-a-world.json"); err == nil {117100 t.Error("an entry the bundle does not hold opened")118101 }119102 }@@ -10,7 +10,7 @@ import (
1010 "sync/atomic"1111 "time"1212 13- "github.com/FelineStateMachine/atlas/format/bundle"13+ "github.com/FelineStateMachine/atlas/format/vnext"1414 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1515 "github.com/FelineStateMachine/atlas/internal/logging"1616 )@@ -22,10 +22,10 @@ const closeGrace = 5 * time.Second
2222 2323 // Volumes is a library kept as a directory of .atlas files.2424 //25-// It scans at construction and on import, and never watches (issue #5 §2,26-// decision 15): a file dropped in from outside appears at the next launch,27-// which is what a reader expects of "open Atlas and see my registry". The28-// fold that decides which build serves is format/bundle's and is pure; this25+// It scans at construction and after installation, and never watches (issue #526+// §2, decision 15): a file copied directly into the library outside Atlas27+// appears at the next launch. Desktop open and drop events install immediately.28+// The fold that decides which build serves is format/vnext's and is pure; this2929 // type is the walk, the open files, and the swap.3030 //3131 // Every rescan builds a whole snapshot and swaps it in at once, so a request@@ -42,7 +42,7 @@ type Volumes struct {
4242 // snapshot is the library at one moment: the winning build per slug, the open4343 // readers behind them, and the order the handler lists them in.4444 type snapshot struct {45- serving map[string]bundle.Descriptor45+ serving map[string]vnext.Descriptor4646 open map[string]*volume4747 order []string4848 }@@ -50,20 +50,27 @@ type snapshot struct {
5050 // volume is one serving build with its archive held open, so answering for an5151 // entry is a seek and a read rather than a fresh open of a file that may be5252 // hundreds of megabytes.53-type volume struct{ reader *bundle.Reader }54-55-func (v *volume) Manifest() bundle.Manifest { return v.reader.Manifest }53+type volume struct {54+ reader *vnext.File55+ locator string56+ info hostenv.VolumeInfo57+ semantic vnext.Volume58+}5659 57-func (v *volume) Open(entry string) (io.ReadCloser, int64, error) {58- return v.reader.OpenEntry(entry)60+func (v *volume) Info() hostenv.VolumeInfo { return v.info }61+func (v *volume) Semantic() vnext.Volume { return v.semantic }62+func (v *volume) Blob(entry string) ([]byte, error) {63+ return v.reader.Blob(entry)5964 }65+func (v *volume) Schema() ([]byte, error) { return v.reader.FileSchema.Canonical() }66+func (v *volume) TableBlock(entry string) ([]byte, error) { return v.reader.TableBlock(entry) }6067 6168 // NewVolumes answers for dir, which need not exist yet: a directory that is6269 // missing or empty is a library with no volumes, not an error. The first scan6370 // happens here, so a caller that gets a store gets a library.6471 func NewVolumes(dir string) (*Volumes, error) {6572 v := &Volumes{dir: dir}66- v.snap.Store(&snapshot{serving: map[string]bundle.Descriptor{}, open: map[string]*volume{}})73+ v.snap.Store(&snapshot{serving: map[string]vnext.Descriptor{}, open: map[string]*volume{}})6774 if _, err := v.Rescan(); err != nil {6875 return nil, err6976 }@@ -92,7 +99,7 @@ func (v *Volumes) Close() error {
9299 v.scanning.Lock()93100 defer v.scanning.Unlock()94101 previous := v.snap.Load()95- v.snap.Store(&snapshot{serving: map[string]bundle.Descriptor{}, open: map[string]*volume{}})102+ v.snap.Store(&snapshot{serving: map[string]vnext.Descriptor{}, open: map[string]*volume{}})96103 var first error97104 for _, held := range previous.open {98105 if err := held.reader.Close(); err != nil && first == nil {@@ -110,7 +117,7 @@ func (v *Volumes) Rescan() ([]string, error) {
110117 v.scanning.Lock()111118 defer v.scanning.Unlock()112119 113- descriptors, skipped, err := bundle.Scan(v.dir)120+ descriptors, skipped, err := vnext.Scan(v.dir, vnext.StandardSchema())114121 if err != nil {115122 return nil, fmt.Errorf("scan library: %w", err)116123 }@@ -120,9 +127,9 @@ func (v *Volumes) Rescan() ([]string, error) {
120127 }121128 122129 previous := v.snap.Load()123- winners := bundle.Fold(descriptors)130+ winners := vnext.Fold(descriptors)124131 next := &snapshot{125- serving: make(map[string]bundle.Descriptor, len(winners)),132+ serving: make(map[string]vnext.Descriptor, len(winners)),126133 open: make(map[string]*volume, len(winners)),127134 order: make([]string, 0, len(winners)),128135 }@@ -141,11 +148,11 @@ func (v *Volumes) Rescan() ([]string, error) {
141148 v.snap.Store(next)142149 143150 retire(previous, next)144- changed := bundle.Changed(previous.serving, next.serving)151+ changed := vnext.Changed(previous.serving, next.serving)145152 for _, slug := range changed {146153 if winner, still := next.serving[slug]; still {147154 slog.Info("volume serving", logging.Op("scan"),148- logging.Volume(slug), logging.Stamp(bundle.ShortStamp(winner.Stamp)))155+ logging.Volume(slug), logging.Stamp(vnext.ShortStamp(winner.Stamp)))149156 } else {150157 slog.Info("volume gone", logging.Op("scan"), logging.Volume(slug))151158 }@@ -179,7 +186,7 @@ func (v *Volumes) Install(name string, content io.Reader) (hostenv.Installed, er
179186 }180187 181188 standing := v.snap.Load().serving182- descriptor, err := bundle.Install(v.dir, stage)189+ descriptor, err := vnext.Install(v.dir, stage, vnext.StandardSchema())183190 if err != nil {184191 return hostenv.Installed{}, fmt.Errorf("%s: %w", name, err)185192 }@@ -191,7 +198,7 @@ func (v *Volumes) Install(name string, content io.Reader) (hostenv.Installed, er
191198 Already: standing[descriptor.Slug].Stamp == descriptor.Stamp,192199 }193200 slog.Info("bundle installed", logging.Op("install"),194- logging.Volume(descriptor.Slug), logging.Stamp(bundle.ShortStamp(descriptor.Stamp)),201+ logging.Volume(descriptor.Slug), logging.Stamp(vnext.ShortStamp(descriptor.Stamp)),195202 logging.Path(descriptor.Locator))196203 197204 changed, err := v.Rescan()@@ -204,18 +211,57 @@ func (v *Volumes) Install(name string, content io.Reader) (hostenv.Installed, er
204211 205212 // carryOrOpen reuses the reader already open for a build whose file has not206213 // moved, and opens the file fresh otherwise.207-func carryOrOpen(previous *snapshot, winner bundle.Descriptor) (*volume, error) {208- if held, ok := previous.open[winner.Slug]; ok && held.reader.Path == winner.Locator {209- if info, err := os.Stat(winner.Locator); err == nil &&210- held.reader.Unchanged(info.Size(), info.ModTime()) {211- return held, nil212- }214+func carryOrOpen(previous *snapshot, winner vnext.Descriptor) (*volume, error) {215+ if held, ok := previous.open[winner.Slug]; ok && held.locator == winner.Locator && held.info.Stamp == winner.Stamp {216+ return held, nil213217 }214- reader, err := bundle.Open(winner.Locator)218+ reader, err := vnext.OpenFile(winner.Locator, vnext.StandardSchema())215219 if err != nil {216220 return nil, err217221 }218- return &volume{reader: reader}, nil222+ semantic, err := reader.Volume()223+ if err != nil {224+ reader.Close()225+ return nil, err226+ }227+ return &volume{reader: reader, locator: winner.Locator, semantic: semantic, info: volumeInfo(winner, semantic)}, nil228+}229+230+func volumeInfo(descriptor vnext.Descriptor, semantic vnext.Volume) hostenv.VolumeInfo {231+ info := hostenv.VolumeInfo{232+ Slug: descriptor.Slug, Title: descriptor.Title, Stamp: descriptor.Stamp,233+ Release: descriptorRelease(descriptor), Worlds: make([]hostenv.WorldInfo, 0, len(semantic.Worlds)),234+ }235+ for index, world := range semantic.Worlds {236+ listed := hostenv.WorldInfo{Slug: world.ID, Title: world.Title, UpdatedAt: descriptor.CreatedAt}237+ for _, set := range world.FeatureSets {238+ for _, feature := range set.Features {239+ switch feature.Geometry.Kind {240+ case vnext.GeometryPoint:241+ listed.Points++242+ case vnext.GeometryLineString:243+ listed.Paths++244+ case vnext.GeometryPolygon:245+ listed.Areas++246+ }247+ }248+ }249+ info.Worlds = append(info.Worlds, listed)250+ if index == 0 {251+ info.TileGrid = hostenv.TileGrid{252+ SourceZoom: int(world.CoordinateSpace.SourceZoom), FirstTile: int(world.CoordinateSpace.FirstTile),253+ TileSize: int(world.CoordinateSpace.TileSize), Size: int(world.CoordinateSpace.Size),254+ }255+ }256+ }257+ return info258+}259+260+func descriptorRelease(descriptor vnext.Descriptor) vnext.Release {261+ return vnext.Release{262+ Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,263+ Stamp: descriptor.Stamp, Worlds: descriptor.Worlds,264+ }219265 }220266 221267 // retire closes the readers the previous snapshot held that the new one did@@ -236,7 +282,7 @@ func retire(previous, next *snapshot) {
236282 for _, old := range closing {237283 if err := old.reader.Close(); err != nil {238284 slog.Warn("closing a retired bundle", logging.Op("scan"),239- logging.Path(old.reader.Path), slog.Any("error", err))285+ logging.Path(old.locator), slog.Any("error", err))240286 }241287 }242288 })@@ -20,11 +20,14 @@ import (
2020 "io"2121 "os"2222 "path/filepath"23+ "sync"2324 "sync/atomic"2425 2526 wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"2627 2728 "github.com/FelineStateMachine/atlas/internal/app/hostenv"29+ "github.com/FelineStateMachine/atlas/internal/logging"30+ "log/slog"2831 )2932 3033 // Window is the live window, or the absence of one.@@ -36,11 +39,73 @@ import (
3639 // opened yet, which is a state a request can genuinely arrive in: the page is3740 // served over the same asset server that the window is still starting.3841 type Window struct {39- live atomic.Pointer[context.Context]42+ live atomic.Pointer[context.Context]43+ mu sync.Mutex44+ install func(name string, content io.Reader) error45+ pending []string4046 }4147 42-// Opened records the window's context. It is the shell's OnStartup.43-func (w *Window) Opened(ctx context.Context) { w.live.Store(&ctx) }48+// Accept gives every native file-open surface the application's one install49+// command. Paths remain a window concern and never enter the application.50+func (w *Window) Accept(install func(name string, content io.Reader) error) {51+ w.mu.Lock()52+ w.install = install53+ w.mu.Unlock()54+}55+56+// QueuePaths accepts launch arguments and second-instance open events. Before57+// startup they wait; after startup they are installed immediately.58+func (w *Window) QueuePaths(paths []string) {59+ w.mu.Lock()60+ w.pending = append(w.pending, atlasPaths(paths)...)61+ live := w.live.Load() != nil62+ w.mu.Unlock()63+ if live {64+ w.flush()65+ }66+}67+68+// Opened records the window context and enables native drag/drop over the69+// entire application surface.70+func (w *Window) Opened(ctx context.Context) {71+ w.live.Store(&ctx)72+ wailsruntime.OnFileDrop(ctx, func(_, _ int, paths []string) { w.QueuePaths(paths) })73+ w.flush()74+}75+76+func (w *Window) flush() {77+ w.mu.Lock()78+ paths := append([]string(nil), w.pending...)79+ w.pending = nil80+ install := w.install81+ w.mu.Unlock()82+ if install == nil {83+ return84+ }85+ for _, path := range paths {86+ file, err := os.Open(path)87+ if err == nil {88+ err = install(filepath.Base(path), file)89+ closeErr := file.Close()90+ if err == nil {91+ err = closeErr92+ }93+ }94+ if err != nil {95+ slog.Warn("native file open refused", logging.Op("install"), logging.Path(path), slog.Any("error", err))96+ }97+ }98+}99+100+func atlasPaths(paths []string) []string {101+ accepted := make([]string, 0, len(paths))102+ for _, path := range paths {103+ if filepath.Ext(path) == ".atlas" {104+ accepted = append(accepted, path)105+ }106+ }107+ return accepted108+}44109 45110 // Pick puts the native file picker in front of the reader and opens what they46111 // chose.@@ -0,0 +1,16 @@
1+package wailshost2+3+import (4+ "reflect"5+ "testing"6+)7+8+func TestAtlasPathsAcceptsOnlyNativeVolumeFiles(t *testing.T) {9+ t.Parallel()10+11+ got := atlasPaths([]string{"/tmp/world.atlas", "/tmp/readme.txt", "/tmp/legacy.zip"})12+ want := []string{"/tmp/world.atlas"}13+ if !reflect.DeepEqual(got, want) {14+ t.Fatalf("atlasPaths = %v, want %v", got, want)15+ }16+}@@ -2,6 +2,7 @@ package app
22 33 import (44 "errors"5+ "io"56 "log/slog"67 "net/http"78 "sync/atomic"@@ -12,8 +13,8 @@ import (
1213 1314 // Adding a volume from inside the application: the host puts a picker in front1415 // of the reader, what they chose is validated and copied into the library, and15-// the library is rescanned -- the same road a hand-dropped file takes, except16-// that a drop is only noticed at the next launch (issue #5 §2, §4.6).16+// the library is rescanned. Native file-open and window-drop events call this17+// same command, so every desktop intake path has identical behavior.1718 //1819 // The response is a stream of rows rather than one answer at the end, because1920 // a hundred-megabyte bundle takes long enough that silence reads as failure.@@ -102,7 +103,7 @@ func (a *App) handleImport(w http.ResponseWriter, r *http.Request) {
102103 defer chosen.Close()103104 104105 row("installing", name)105- installed, err := a.env.Volumes().Install(name, chosen)106+ installed, err := a.Install(name, chosen)106107 if err != nil {107108 slog.Warn("import refused", logging.Op("install"),108109 logging.Path(name), slog.Any("error", err))@@ -115,10 +116,18 @@ func (a *App) handleImport(w http.ResponseWriter, r *http.Request) {
115116 row("installed", installed.Title)116117 }117118 118- // The rescan happened inside the store; what it moved is announced to119- // every open page, which is how a second window learns about an import120- // performed in the first.119+}120+121+// Install is the single native intake command used by the picker, desktop122+// file-open events, and drag/drop. It validates, atomically installs, rescans,123+// and announces the changed semantic volumes.124+func (a *App) Install(name string, content io.Reader) (hostenv.Installed, error) {125+ installed, err := a.env.Volumes().Install(name, content)126+ if err != nil {127+ return hostenv.Installed{}, err128+ }121129 a.announce(installed.Changed)130+ return installed, nil122131 }123132 124133 // writeRegion renders the import region, holding the one row that is true now@@ -9,6 +9,7 @@ import (
99 "strings"1010 1111 "github.com/FelineStateMachine/atlas/format/bundle"12+ "github.com/FelineStateMachine/atlas/format/vnext"1213 "github.com/FelineStateMachine/atlas/internal/logging"1314 )1415 @@ -31,13 +32,13 @@ func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {
3132 if !serving {3233 volume = held.order[0]3334 }34- manifest := volume.Manifest()35- session := a.session(manifest.Volume.Slug)35+ info := volume.Info()36+ session := a.session(info.Slug)3637 world := session.World37- if _, ok := worldEntry(manifest, world); !ok {38- world = manifest.Worlds[0].Slug38+ if _, ok := worldEntry(info, world); !ok {39+ world = info.Worlds[0].Slug3940 }40- http.Redirect(w, r, "/v/"+manifest.Volume.Slug+"/"+world, http.StatusFound)41+ http.Redirect(w, r, "/v/"+info.Slug+"/"+world, http.StatusFound)4142 }4243 4344 // handleOpen is the doorway the volume and world selects go through.@@ -59,15 +60,15 @@ func (a *App) handleOpen(w http.ResponseWriter, r *http.Request) {
5960 }6061 volume = held.order[0]6162 }62- manifest := volume.Manifest()63+ info := volume.Info()6364 world := r.URL.Query().Get("world")64- if _, ok := worldEntry(manifest, world); !ok {65- world = a.session(manifest.Volume.Slug).World65+ if _, ok := worldEntry(info, world); !ok {66+ world = a.session(info.Slug).World6667 }67- if _, ok := worldEntry(manifest, world); !ok {68- world = manifest.Worlds[0].Slug68+ if _, ok := worldEntry(info, world); !ok {69+ world = info.Worlds[0].Slug6970 }70- http.Redirect(w, r, "/v/"+manifest.Volume.Slug+"/"+world, http.StatusFound)71+ http.Redirect(w, r, "/v/"+info.Slug+"/"+world, http.StatusFound)7172 }7273 7374 // handleExplorer serves one world of one volume: the whole page, server@@ -84,8 +85,8 @@ func (a *App) handleExplorer(w http.ResponseWriter, r *http.Request) {
8485 http.NotFound(w, r)8586 return8687 }87- manifest := volume.Manifest()88- if _, ok := worldEntry(manifest, world); !ok {88+ info := volume.Info()89+ if _, ok := worldEntry(info, world); !ok {8990 http.NotFound(w, r)9091 return9192 }@@ -117,7 +118,7 @@ func (a *App) handleExplorer(w http.ResponseWriter, r *http.Request) {
117118 session.Focused = ""118119 }119120 session.World = world120- session.Stamp = bundle.ShortStamp(manifest.Version.Stamp)121+ session.Stamp = vnext.ShortStamp(info.Stamp)121122 a.arrange(volume, &session)122123 if err := a.saveSession(&session); err != nil {123124 slog.Warn("the session could not be written", logging.Op("session"),@@ -12,6 +12,7 @@ import (
1212 "time"1313 1414 "github.com/FelineStateMachine/atlas/format/bundle"15+ "github.com/FelineStateMachine/atlas/format/vnext"1516 "github.com/FelineStateMachine/atlas/internal/app/cells"1617 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1718 "github.com/FelineStateMachine/atlas/internal/logging"@@ -379,7 +380,7 @@ func (a *App) handleSession(w http.ResponseWriter, r *http.Request) {
379380 }380381 381382 session := a.session(slug)382- session.Stamp = bundle.ShortStamp(volume.Manifest().Version.Stamp)383+ session.Stamp = vnext.ShortStamp(volume.Info().Stamp)383384 a.arrange(volume, &session)384385 ctx := &concernContext{session: &session, world: a.world(volume, session.World)}385386 if err := held.apply(ctx, form); err != nil {@@ -428,13 +429,13 @@ func (a *App) resetSession(w http.ResponseWriter, volume hostenv.Volume, slug st
428429 // reset pressed twice -- falls back to the volume's first world, and a429430 // volume with no worlds to fall back to falls all the way back to /,430431 // which sends the reader to the volume they were last in.431- manifest := volume.Manifest()432+ info := volume.Info()432433 held := a.session(slug)433434 world := held.World434- if _, serving := worldEntry(manifest, world); !serving {435+ if _, serving := worldEntry(info, world); !serving {435436 world = ""436- if len(manifest.Worlds) > 0 {437- world = manifest.Worlds[0].Slug437+ if len(info.Worlds) > 0 {438+ world = info.Worlds[0].Slug438439 }439440 }440441 where := "/"@@ -477,11 +478,11 @@ func (a *App) arrange(volume hostenv.Volume, s *Session) {
477478 return478479 }479480 if s.World == "" {480- manifest := volume.Manifest()481- if len(manifest.Worlds) == 0 {481+ info := volume.Info()482+ if len(info.Worlds) == 0 {482483 return483484 }484- s.World = manifest.Worlds[0].Slug485+ s.World = info.Worlds[0].Slug485486 }486487 model := a.world(volume, s.World)487488 if model == nil {@@ -7,8 +7,8 @@ import (
77 "strconv"88 "strings"99 10- "github.com/FelineStateMachine/atlas/format/bundle"1110 "github.com/FelineStateMachine/atlas/format/semconv"11+ "github.com/FelineStateMachine/atlas/format/vnext"1212 "github.com/FelineStateMachine/atlas/internal/app/cells"1313 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1414 )@@ -242,17 +242,13 @@ func (a *App) view(held library, volume hostenv.Volume, session Session) View {
242242 }243243 current := ""244244 if volume != nil {245- current = volume.Manifest().Volume.Slug245+ current = volume.Info().Slug246246 }247247 for _, listed := range held.order {248- manifest := listed.Manifest()248+ info := listed.Info()249249 out.Library = append(out.Library, LibraryEntry{250- Slug: manifest.Volume.Slug,251- Title: manifest.Volume.Title,252- Stamp: bundle.ShortStamp(manifest.Version.Stamp),253- Base: volumeBase(manifest),254- Worlds: len(manifest.Worlds),255- Current: manifest.Volume.Slug == current,250+ Slug: info.Slug, Title: info.Title, Stamp: vnext.ShortStamp(info.Stamp),251+ Base: volumeBase(info), Worlds: len(info.Worlds), Current: info.Slug == current,256252 })257253 }258254 if volume == nil {@@ -260,18 +256,15 @@ func (a *App) view(held library, volume hostenv.Volume, session Session) View {
260256 return out261257 }262258 263- manifest := volume.Manifest()259+ info := volume.Info()264260 shown := VolumeView{265- Slug: manifest.Volume.Slug,266- Title: manifest.Volume.Title,267- Stamp: bundle.ShortStamp(manifest.Version.Stamp),268- Base: volumeBase(manifest),261+ Slug: info.Slug, Title: info.Title, Stamp: vnext.ShortStamp(info.Stamp), Base: volumeBase(info),269262 }270263 world := session.World271- if _, ok := worldEntry(manifest, world); !ok {272- world = manifest.Worlds[0].Slug264+ if _, ok := worldEntry(info, world); !ok {265+ world = info.Worlds[0].Slug273266 }274- for _, entry := range manifest.Worlds {267+ for _, entry := range info.Worlds {275268 listed := WorldView{276269 Slug: entry.Slug,277270 Title: entry.Title,@@ -303,7 +296,7 @@ func (a *App) view(held library, volume hostenv.Volume, session Session) View {
303296 shown.Sphere = model.Attrs[semconv.KeyGeometrySurface] == "sphere"304297 }305298 out.Volume = &shown306- out.Title = manifest.Volume.Title299+ out.Title = info.Title307300 308301 standing := visible(model, session, lens)309302 out.Legend = legend(model, session, standing, lens, shown.Base)@@ -317,7 +310,7 @@ func (a *App) view(held library, volume hostenv.Volume, session Session) View {
317310 out.Overview.Label = "Put the overview away"318311 }319312 out.Viewport = viewportView(shown, session)320- out.Island = island(manifest.Volume.Slug, model, &shown, session)313+ out.Island = island(info.Slug, model, &shown, session)321314 return out322315 }323316 @@ -571,12 +564,12 @@ func nonEmpty(values ...string) []string {
571564 return out572565 }573566 574-// worldEntry finds one world in a manifest.575-func worldEntry(m bundle.Manifest, slug string) (bundle.WorldEntry, bool) {576- for _, entry := range m.Worlds {567+// worldEntry finds one world in the native volume summary.568+func worldEntry(info hostenv.VolumeInfo, slug string) (hostenv.WorldInfo, bool) {569+ for _, entry := range info.Worlds {577570 if entry.Slug == slug {578571 return entry, true579572 }580573 }581- return bundle.WorldEntry{}, false574+ return hostenv.WorldInfo{}, false582575 }@@ -13,14 +13,14 @@ import (
1313 func TestVNextEarthProjectsToTheEstablishedRendererContract(t *testing.T) {1414 t.Parallel()1515 16- legacy, err := bundle.Open(includedEarthPath(t))16+ native, err := vnext.OpenFile(includedEarthPath(t), vnext.StandardSchema())1717 if err != nil {18- t.Fatalf("open v3 Earth: %v", err)18+ t.Fatalf("open native Earth: %v", err)1919 }20- defer legacy.Close()21- semantic, err := vnext.ImportV3Volume(legacy.Manifest, legacy.ReadEntry)20+ defer native.Close()21+ semantic, err := native.Volume()2222 if err != nil {23- t.Fatalf("import v3 Earth: %v", err)23+ t.Fatalf("restore native Earth: %v", err)2424 }2525 2626 payload, locations, texts, err := presentVNextWorld(semantic.Worlds[0])@@ -57,14 +57,14 @@ func TestVNextEarthProjectsToTheEstablishedRendererContract(t *testing.T) {
5757 func TestVNextEarthServesTheThreeRendererPayloads(t *testing.T) {5858 t.Parallel()5959 60- legacy, err := bundle.Open(includedEarthPath(t))60+ native, err := vnext.OpenFile(includedEarthPath(t), vnext.StandardSchema())6161 if err != nil {62- t.Fatalf("open v3 Earth: %v", err)62+ t.Fatalf("open native Earth: %v", err)6363 }64- defer legacy.Close()65- semantic, err := vnext.ImportV3Volume(legacy.Manifest, legacy.ReadEntry)64+ defer native.Close()65+ semantic, err := native.Volume()6666 if err != nil {67- t.Fatalf("import v3 Earth: %v", err)67+ t.Fatalf("restore native Earth: %v", err)6868 }6969 world := semantic.Worlds[0]7070 @@ -105,7 +105,11 @@ func includedEarthPath(t *testing.T) string {
105105 if !ok {106106 t.Fatal("locate projection test")107107 }108- return filepath.Join(filepath.Dir(file), "..", "..", "included", "earth-20260803-76633cb323ec.atlas")108+ paths, err := filepath.Glob(filepath.Join(filepath.Dir(file), "..", "..", "included", "earth-*.atlas"))109+ if err != nil || len(paths) != 1 {110+ t.Fatalf("locate native Earth: %v, %v", paths, err)111+ }112+ return paths[0]109113 }110114 111115 func projectedCollection(payload worldPayload, title string) *payloadCollection {@@ -48,12 +48,10 @@ func (cache *semanticCache) load(key string, importVolume func() (vnext.Volume,
4848 }4949 5050 func (a *App) semanticVolume(source hostenv.Volume) (vnext.Volume, error) {51- manifest := source.Manifest()52- key := manifest.Volume.Slug + "@" + bundle.ShortStamp(manifest.Version.Stamp)51+ info := source.Info()52+ key := info.Slug + "@" + vnext.ShortStamp(info.Stamp)5353 return a.semantic.load(key, func() (vnext.Volume, error) {54- return vnext.ImportV3Volume(manifest, func(name string) ([]byte, error) {55- return readEntry(source, name)56- })54+ return source.Semantic(), nil5755 })5856 }5957 @@ -2,8 +2,6 @@ package app
22 33 import (44 "encoding/json"5- "fmt"6- "io"75 "math"86 "sort"97 "strconv"@@ -11,6 +9,7 @@ import (
119 1210 "github.com/FelineStateMachine/atlas/format/bundle"1311 "github.com/FelineStateMachine/atlas/format/semconv"12+ "github.com/FelineStateMachine/atlas/format/vnext"1413 "github.com/FelineStateMachine/atlas/internal/app/cells"1514 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1615 )@@ -287,8 +286,8 @@ func (c *worldCache) put(key string, model *worldModel) {
287286 // page: the chrome still renders, the legend is simply empty, and the failure288287 // is in the log where somebody can act on it.289288 func (a *App) world(volume hostenv.Volume, slug string) *worldModel {290- manifest := volume.Manifest()291- key := manifest.Volume.Slug + "@" + bundle.ShortStamp(manifest.Version.Stamp) + "/" + slug289+ info := volume.Info()290+ key := info.Slug + "@" + vnext.ShortStamp(info.Stamp) + "/" + slug292291 if held, ok := a.worlds.get(key); ok {293292 return held294293 }@@ -497,8 +496,8 @@ type featureText struct {
497496 // entry because it is a single JSON object and there is nothing to seek to;498497 // it is small beside the tiles and it is read only when a card opens.499498 func (a *App) text(volume hostenv.Volume, world string) map[string]featureText {500- manifest := volume.Manifest()501- key := manifest.Volume.Slug + "@" + bundle.ShortStamp(manifest.Version.Stamp) + "/" + world + ".text"499+ info := volume.Info()500+ key := info.Slug + "@" + vnext.ShortStamp(info.Stamp) + "/" + world + ".text"502501 if held, ok := a.texts.get(key); ok {503502 return held504503 }@@ -545,21 +544,6 @@ func (c *textCache) put(key string, held map[string]featureText) {
545544 c.held[key] = held546545 }547546 548-// readEntry reads one archive entry whole.549-func readEntry(volume hostenv.Volume, name string) ([]byte, error) {550- entry, size, err := volume.Open(name)551- if err != nil {552- return nil, fmt.Errorf("open %s: %w", name, err)553- }554- defer entry.Close()555- _ = size556- out, err := io.ReadAll(entry)557- if err != nil {558- return nil, fmt.Errorf("read %s: %w", name, err)559- }560- return out, nil561-}562-563547 // sortedIDs is the order a set of collection ids is written in everywhere it564548 // is written: as strings, which is how the session record and the state565549 // island both spell it (docs/app.md §6).@@ -51,7 +51,8 @@ import (
5151 // 7 attribute-level merge resolution; ledgers name canonical source slugs5252 // 8 shape prose defers to the text payload; shapes mark hasText5353 // 9 format v3: one collections array of point, path, and area features54-const PolicyRevision = 954+// 10 native vNext semantic tables, embedded schema, and opaque raster blobs55+const PolicyRevision = 105556 5657 // Options is one composition.5758 type Options struct {@@ -0,0 +1,259 @@
1+package compose2+3+import (4+ "encoding/base64"5+ "encoding/json"6+ "fmt"7+ "mime"8+ "path/filepath"9+ "sort"10+ "strconv"11+ "strings"12+13+ "github.com/FelineStateMachine/atlas/format/vnext"14+ "github.com/FelineStateMachine/atlas/internal/generate/doc"15+ "github.com/FelineStateMachine/atlas/internal/generate/tiles"16+)17+18+func nativeVolume(options Options, worlds []composedWorld, icons map[string][]byte) (vnext.Volume, []vnext.Blob, int, string, error) {19+ volume := vnext.Volume{ID: options.Document.Volume.Slug, Title: options.Document.Volume.Title}20+ pyramids := make(map[string]tiles.Pyramid)21+ capturedAt := ""22+ for _, source := range worlds {23+ world, err := nativeWorld(options, source, pyramids)24+ if err != nil {25+ return vnext.Volume{}, nil, 0, "", fmt.Errorf("world %s: %w", source.Slug, err)26+ }27+ volume.Worlds = append(volume.Worlds, world)28+ if source.CapturedAt > capturedAt {29+ capturedAt = source.CapturedAt30+ }31+ }32+ if capturedAt == "" {33+ return vnext.Volume{}, nil, 0, "", fmt.Errorf("no world carries a capture time to version the bundle by")34+ }35+ iconNames := make([]string, 0, len(icons))36+ for name := range icons {37+ iconNames = append(iconNames, name)38+ }39+ sort.Strings(iconNames)40+ for _, name := range iconNames {41+ mediaType := mime.TypeByExtension(filepath.Ext(name))42+ if mediaType == "" {43+ mediaType = "application/octet-stream"44+ }45+ volume.Assets = append(volume.Assets, vnext.Asset{ID: name, MediaType: mediaType, Data: icons[name], Provenance: options.Document.Volume.Slug})46+ }47+ blobs, tileCount, err := rasterBlobList(options.Tiles, pyramids)48+ return volume, blobs, tileCount, capturedAt, err49+}50+51+func nativeWorld(options Options, source composedWorld, pyramids map[string]tiles.Pyramid) (vnext.World, error) {52+ grid := source.Grid53+ if grid == nil {54+ grid = &worldGrid{SourceZoom: options.Curation.Window.SourceZoom, FirstTile: options.Curation.Window.FirstTile}55+ }56+ world := vnext.World{57+ ID: source.Slug, Title: source.Title,58+ CoordinateSpace: vnext.CoordinateSpace{59+ ID: source.Slug + "/space", Kind: "projected", Unit: "pixel", Definition: "atlas:tile-plane",60+ Extent: [4]float64{0, 0, float64(options.Tiles.Size), float64(options.Tiles.Size)},61+ SourceZoom: int64(grid.SourceZoom), FirstTile: int64(grid.FirstTile),62+ TileSize: int64(options.Tiles.TileSize), Size: int64(options.Tiles.Size),63+ },64+ Claims: stringProperties("world", source.Attrs, true),65+ Presentation: vnext.Presentation{ID: source.Slug + "/presentation", Title: source.Title},66+ }67+ for index, collection := range source.Collections {68+ set, style, layer, err := nativeCollection(options.Document.Source.Name, source, collection, index)69+ if err != nil {70+ return vnext.World{}, err71+ }72+ world.FeatureSets = append(world.FeatureSets, set)73+ world.Presentation.Styles = append(world.Presentation.Styles, style)74+ world.Presentation.Layers = append(world.Presentation.Layers, layer)75+ world.Presentation.Legend = append(world.Presentation.Legend, vnext.LegendEntry{Layer: layer.ID, Label: collection.Title, Order: int64(index)})76+ }77+ for index, lens := range source.Lenses {78+ pyramid := source.Pyramids[index]79+ pyramids[lens.Tiles] = pyramid80+ raster, err := nativeRaster(source.Slug, options.Tiles.TileSize, index, lens)81+ if err != nil {82+ return vnext.World{}, err83+ }84+ world.RasterPyramids = append(world.RasterPyramids, raster)85+ }86+ return world, nil87+}88+89+func nativeCollection(provenanceSource string, world composedWorld, source composedCollection, order int) (vnext.FeatureSet, vnext.Style, vnext.Layer, error) {90+ key := source.Key91+ if key == "" {92+ key = strconv.FormatInt(source.ID, 10)93+ }94+ setID := world.Slug + "/set/" + key95+ set := vnext.FeatureSet{ID: setID, Title: source.Title, SemanticType: "geometry." + source.Kind, Claims: stringProperties("featureSet", source.Attrs, true)}96+ contract := make(map[vnext.ID]vnext.Field)97+ for _, item := range source.Features {98+ feature, err := nativeFeature(provenanceSource, world, item)99+ if err != nil {100+ return vnext.FeatureSet{}, vnext.Style{}, vnext.Layer{}, fmt.Errorf("feature %d: %w", item.ID, err)101+ }102+ for _, property := range feature.Properties {103+ contract[property.Field.ID] = property.Field104+ }105+ set.Features = append(set.Features, feature)106+ }107+ for _, field := range contract {108+ set.Properties = append(set.Properties, field)109+ }110+ sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })111+ styleID := world.Slug + "/style/" + key112+ style := vnext.Style{113+ ID: styleID, Icon: source.Icon, IconAsset: source.IconAsset, IconPicture: source.IconPicture,114+ Stroke: source.IconColor, Fill: source.Color,115+ }116+ layer := vnext.Layer{117+ ID: world.Slug + "/layer/" + key, FeatureSet: setID, Style: styleID,118+ Group: source.Group, Visible: source.Visible, Order: int64(order), MaxZoom: 32,119+ }120+ return set, style, layer, nil121+}122+123+func nativeFeature(provenanceSource string, world composedWorld, source composedFeature) (vnext.Feature, error) {124+ id := world.Slug + "/feature/" + strconv.FormatInt(source.ID, 10)125+ feature := vnext.Feature{126+ ID: id, Title: source.Title, Subtitle: source.Subtitle, Description: source.Description,127+ Shard: source.Shard, Properties: stringProperties("feature", source.Attrs, false),128+ Provenance: []vnext.Provenance{{Source: provenanceSource, NativeID: strconv.FormatInt(source.ID, 10), CapturedAt: world.CapturedAt}},129+ }130+ if source.At != nil {131+ feature.Geometry = vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{source.At.Lng, source.At.Lat}}}}}}132+ } else {133+ geometry, err := nativeGeometry(source.Geometry)134+ if err != nil {135+ return vnext.Feature{}, err136+ }137+ feature.Geometry = geometry138+ }139+ if source.Center != nil {140+ center := vnext.Position{source.Center.Lng, source.Center.Lat}141+ feature.Center = ¢er142+ }143+ if source.Member != 0 {144+ feature.Relationships = append(feature.Relationships, vnext.Relationship{Predicate: "within", Target: world.Slug + "/feature/" + strconv.FormatInt(source.Member, 10)})145+ }146+ if source.Parent != 0 {147+ feature.Relationships = append(feature.Relationships, vnext.Relationship{Predicate: "within", Target: world.Slug + "/feature/" + strconv.FormatInt(source.Parent, 10)})148+ }149+ for _, link := range source.Links {150+ feature.Relationships = append(feature.Relationships, vnext.Relationship{Predicate: "references", Target: world.Slug + "/feature/" + strconv.FormatInt(link.Feature, 10)})151+ }152+ return feature, nil153+}154+155+func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {156+ var geometry vnext.Geometry157+ for _, item := range items {158+ switch item.Type {159+ case "Point":160+ var point vnext.Position161+ if err := json.Unmarshal(item.Coordinates, &point); err != nil {162+ return vnext.Geometry{}, err163+ }164+ geometry.Kind, geometry.Parts = vnext.GeometryPoint, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{{point}}})165+ case "LineString":166+ var line []vnext.Position167+ if err := json.Unmarshal(item.Coordinates, &line); err != nil {168+ return vnext.Geometry{}, err169+ }170+ geometry.Kind, geometry.Parts = vnext.GeometryLineString, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{line}})171+ case "MultiLineString":172+ var lines [][]vnext.Position173+ if err := json.Unmarshal(item.Coordinates, &lines); err != nil {174+ return vnext.Geometry{}, err175+ }176+ geometry.Kind = vnext.GeometryLineString177+ for _, line := range lines {178+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{line}})179+ }180+ case "Polygon":181+ var rings [][]vnext.Position182+ if err := json.Unmarshal(item.Coordinates, &rings); err != nil {183+ return vnext.Geometry{}, err184+ }185+ geometry.Kind, geometry.Parts = vnext.GeometryPolygon, append(geometry.Parts, vnext.GeometryPart{Rings: rings})186+ case "MultiPolygon":187+ var polygons [][][]vnext.Position188+ if err := json.Unmarshal(item.Coordinates, &polygons); err != nil {189+ return vnext.Geometry{}, err190+ }191+ geometry.Kind = vnext.GeometryPolygon192+ for _, rings := range polygons {193+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: rings})194+ }195+ default:196+ return vnext.Geometry{}, fmt.Errorf("unsupported geometry %q", item.Type)197+ }198+ }199+ return geometry, nil200+}201+202+func nativeRaster(world string, tileSize, index int, source lens) (vnext.RasterPyramid, error) {203+ raster := vnext.RasterPyramid{204+ ID: world + "/raster/" + strconv.Itoa(index), Name: source.Name, Codec: rasterCodec(source.Formats),205+ TileSize: int64(tileSize), MinZoom: int64(source.MinZoom), MaxZoom: int64(source.MaxZoom), FullZoom: int64(source.FullZoom), SourceZoom: int64(source.SourceZoom),206+ Template: "tiles/" + source.Tiles + "/{z}/{x}/{y}.{format}", Formats: source.Formats,207+ Bounds: nativeBox(source.Bounds), Surface: nativeBox(source.Surface), Interpolate: source.Interpolate, Background: source.Background, Shard: source.Shard,208+ }209+ levels := make([]string, 0, len(source.Coverage))210+ for level := range source.Coverage {211+ levels = append(levels, level)212+ }213+ sort.Strings(levels)214+ for _, level := range levels {215+ zoom, err := strconv.ParseInt(level, 10, 64)216+ if err != nil {217+ return vnext.RasterPyramid{}, err218+ }219+ coverage := source.Coverage[level]220+ bits, err := base64.StdEncoding.DecodeString(coverage.Bits)221+ if err != nil {222+ return vnext.RasterPyramid{}, err223+ }224+ raster.Coverage = append(raster.Coverage, vnext.RasterCoverage{Zoom: zoom, X: int64(coverage.X), Y: int64(coverage.Y), W: int64(coverage.W), H: int64(coverage.H), Bits: bits})225+ }226+ return raster, nil227+}228+229+func nativeBox(box *tiles.Box) *vnext.RasterRect {230+ if box == nil {231+ return nil232+ }233+ return &vnext.RasterRect{X: float64(box.X), Y: float64(box.Y), Width: float64(box.Width), Height: float64(box.Height)}234+}235+236+func rasterCodec(formats []string) string {237+ if len(formats) == 0 {238+ return "application/octet-stream"239+ }240+ extension := strings.TrimPrefix(formats[len(formats)-1], ".")241+ if extension == "jpg" {242+ extension = "jpeg"243+ }244+ return "image/" + extension245+}246+247+func stringProperties(owner string, attrs map[string]string, optional bool) []vnext.Property {248+ keys := make([]string, 0, len(attrs))249+ for key := range attrs {250+ keys = append(keys, key)251+ }252+ sort.Strings(keys)253+ properties := make([]vnext.Property, 0, len(keys))254+ for _, key := range keys {255+ field := vnext.Field{ID: vnext.IDFromName("dev.atlas.attribute."+owner, key), Name: key, Kind: vnext.KindString, Optional: optional}256+ properties = append(properties, vnext.Property{FieldID: field.ID, Field: field, Value: vnext.StringValue(attrs[key])})257+ }258+ return properties259+}@@ -1,7 +1,6 @@
11 package compose22 33 import (4- "bytes"54 "encoding/json"65 "fmt"76 "log/slog"@@ -9,283 +8,175 @@ import (
98 "path/filepath"109 "sort"1110 12- "github.com/FelineStateMachine/atlas/format/bundle"13- "github.com/FelineStateMachine/atlas/format/semconv"11+ "github.com/FelineStateMachine/atlas/format/vnext"1412 "github.com/FelineStateMachine/atlas/internal/generate/tiles"1513 "github.com/FelineStateMachine/atlas/internal/logging"1614 )1715 18-// revisionOf is the revision this build carries: the caller's, where it has one19-// to state, and this lane's own policy revision otherwise.20-func revisionOf(o Options) int {21- if o.Revision != 0 {22- return o.Revision16+func revisionOf(options Options) int {17+ if options.Revision != 0 {18+ return options.Revision2319 }2420 return PolicyRevision2521 }2622 27-// payloadParts is one world's three payloads, built and hashed before anything28-// is written: the stamp has to be known before the file can be named, and the29-// file cannot be named before it is opened.30-type payloadParts struct {31- slug string32- detail []byte33- packed []byte34- text []byte35-}36-37-// write stamps the volume, decides whether it needs writing at all, and writes38-// it if it does.39-//40-// The stamp is taken over named parts rather than over the finished archive, so41-// tile pyramids weigh into it through their derivation stamps without a single42-// raster being read. The manifest is hashed as it stands before its own stamp43-// and creation time are filled in -- a stamp cannot cover itself -- and the44-// revision is included, so a policy change supersedes the builds already in45-// every library.46-func write(o Options, worlds []composedWorld, icons map[string][]byte, log *slog.Logger) (Result, error) {47- manifest := bundle.Manifest{48- Format: bundle.Format,49- FormatVersion: bundle.FormatVersion,50- Conventions: semconv.Version,51- Volume: bundle.Volume{52- Slug: o.Document.Volume.Slug,53- Title: o.Document.Volume.Title,54- },55- Version: bundle.Version{Revision: revisionOf(o)},56- TileGrid: bundle.TileGrid{57- SourceZoom: o.Curation.Window.SourceZoom,58- FirstTile: o.Curation.Window.FirstTile,59- TileSize: o.Tiles.TileSize,60- Size: o.Tiles.Size,61- },23+// write builds exactly one native semantic bundle. The producer never creates24+// the retired document-shaped archive, even as an intermediate artifact.25+func write(options Options, worlds []composedWorld, icons map[string][]byte, log *slog.Logger) (Result, error) {26+ semantic, rasterBlobs, tileCount, capturedAt, err := nativeVolume(options, worlds, icons)27+ if err != nil {28+ return Result{}, err6229 }63-64- var stamp bundle.Stamp65- var parts []payloadParts66- // pyramids maps a bundle-local pyramid name to the tile set's own name for67- // it. Several worlds may draw from one pyramid -- pieces of a split sheet do68- // -- so it is a map, and the pyramid travels once.69- pyramids := make(map[string]tiles.Pyramid)70-71- for _, world := range worlds {72- payload, packed, text := buildPayload(world)73- for index, lens := range payload.Lenses {74- pyramids[lens.Tiles] = world.Pyramids[index]75- }76- detail, err := json.Marshal(payload)77- if err != nil {78- return Result{}, fmt.Errorf("world %s: marshal payload: %w", world.Slug, err)79- }80- textJSON, err := json.Marshal(text)81- if err != nil {82- return Result{}, fmt.Errorf("world %s: marshal text: %w", world.Slug, err)83- }84- parts = append(parts, payloadParts{slug: world.Slug, detail: detail, packed: packed, text: textJSON})85- stamp.Add(bundle.WorldEntryName(world.Slug, bundle.WorldSuffix), bundle.HashBytes(detail))86- stamp.Add(bundle.WorldEntryName(world.Slug, bundle.PackedSuffix), bundle.HashBytes(packed))87- stamp.Add(bundle.WorldEntryName(world.Slug, bundle.TextSuffix), bundle.HashBytes(textJSON))88-89- count := tally(world.Collections)90- manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{91- Slug: world.Slug,92- Title: world.Title,93- Parent: world.Parent,94- IconOutset: world.IconOutset,95- Center: bundle.Coordinate{Lat: world.Center.Lat, Lng: world.Center.Lng},96- Points: count.Point,97- Paths: count.Path,98- Areas: count.Area,99- UpdatedAt: world.CapturedAt,100- })30+ packed, err := vnext.Compile(semantic)31+ if err != nil {32+ return Result{}, fmt.Errorf("compile native volume: %w", err)10133 }34+ packed.Release.CreatedAt = capturedAt35+ packed.Release.Revision = revisionOf(options)36+ packed.Blobs = append(packed.Blobs, rasterBlobs...)10237 103- // A pyramid's own stamp already names the source tiles and the tool that104- // derived them, so the tiles weigh into the bundle's stamp without being105- // read until the bundle is actually written.106- for local, pyramid := range pyramids {107- stamp.Add(tiles.StampPart(local), pyramid.Stamp)108- }109- iconNames := make([]string, 0, len(icons))110- for name, data := range icons {111- iconNames = append(iconNames, name)112- stamp.Add("icons/"+name, bundle.HashBytes(data))38+ stageDir := options.BundleDir39+ if stageDir == "" {40+ stageDir = os.TempDir()41+ } else if err := os.MkdirAll(stageDir, 0o755); err != nil {42+ return Result{}, err11343 }114- sort.Strings(iconNames)115-116- identity, err := bundle.MarshalManifest(manifest)44+ file, err := os.CreateTemp(stageDir, semantic.ID+".atlas.tmp-*")11745 if err != nil {11846 return Result{}, err11947 }120- stamp.Add(bundle.ManifestName, bundle.HashBytes(identity))121- manifest.Version.Stamp = stamp.Sum()122-123- // The version is the capture, not the build: the newest capture time across124- // the volume's worlds. Building the same archive anywhere, at any time,125- // yields the same version and the same file name, which is what lets a126- // directory of these files stand as a registry.127- for _, world := range worlds {128- if world.CapturedAt > manifest.Version.CreatedAt {129- manifest.Version.CreatedAt = world.CapturedAt130- }48+ stage := file.Name()49+ defer os.Remove(stage)50+ if err := vnext.Write(file, packed); err != nil {51+ file.Close()52+ return Result{}, err13153 }132- if manifest.Version.CreatedAt == "" {133- return Result{}, fmt.Errorf("no world carries a capture time to version the bundle by")54+ if err := file.Sync(); err != nil {55+ file.Close()56+ return Result{}, err13457 }135-136- tileCount := 0137- for _, pyramid := range pyramids {138- list, err := o.Tiles.Tiles(pyramid)139- if err != nil {140- return Result{}, err141- }142- tileCount += len(list)58+ if err := file.Close(); err != nil {59+ return Result{}, err60+ }61+ opened, err := vnext.OpenFile(stage, vnext.StandardSchema())62+ if err != nil {63+ return Result{}, fmt.Errorf("reopen native volume: %w", err)14364 }65+ if err := opened.Validate(); err != nil {66+ opened.Close()67+ return Result{}, fmt.Errorf("validate native volume: %w", err)68+ }69+ descriptor := opened.Descriptor()70+ if err := opened.Close(); err != nil {71+ return Result{}, err72+ }73+14474 result := Result{145- Volume: manifest.Volume.Slug,146- Stamp: manifest.Version.Stamp,147- File: bundle.VersionedFileName(manifest),148- Worlds: len(worlds),149- Tiles: tileCount,150- Icons: len(iconNames),75+ Volume: semantic.ID, Stamp: descriptor.Stamp,76+ File: vnext.VersionedFileName(semantic.ID, descriptorRelease(descriptor)),77+ Worlds: len(worlds), Tiles: tileCount, Icons: len(icons),15178 }152- if o.BundleDir == "" {153- log.Info("volume composed", logging.Stamp(bundle.ShortStamp(result.Stamp)),79+ if options.BundleDir == "" {80+ log.Info("native volume composed", logging.Stamp(vnext.ShortStamp(result.Stamp)),15481 "worlds", result.Worlds, "tiles", result.Tiles, "icons", result.Icons)15582 return result, nil15683 }15784 158- if err := os.MkdirAll(o.BundleDir, 0o755); err != nil {159- return Result{}, err160- }161- target := filepath.Join(o.BundleDir, result.File)85+ target := filepath.Join(options.BundleDir, result.File)16286 result.Path = target163- // The name carries the stamp, so a file of that name is that build. There164- // is nothing to write and nothing to check.16587 if _, err := os.Stat(target); err == nil {16688 result.Present = true167- log.Info("build already installed", logging.Stamp(bundle.ShortStamp(result.Stamp)),168- logging.Path(result.File))89+ log.Info("native build already installed", logging.Stamp(vnext.ShortStamp(result.Stamp)), logging.Path(result.File))16990 return result, nil17091 }171- if err := install(o, manifest, parts, pyramids, icons, iconNames, target); err != nil {92+ if err := os.Chmod(stage, 0o644); err != nil {93+ return Result{}, err94+ }95+ if err := os.Rename(stage, target); err != nil {17296 return Result{}, err17397 }174- log.Info("bundle installed", logging.Stamp(bundle.ShortStamp(result.Stamp)),175- logging.Path(result.File), "worlds", result.Worlds,176- "tiles", result.Tiles, "icons", result.Icons)98+ log.Info("native bundle installed", logging.Stamp(vnext.ShortStamp(result.Stamp)),99+ logging.Path(result.File), "worlds", result.Worlds, "tiles", result.Tiles, "icons", result.Icons)177100 return result, nil178101 }179102 180-// install writes the bundle beside the registry it belongs to and then proves181-// it.182-//183-// The sequence is deliberate. The archive is built under a temporary name that184-// does not end in the bundle extension, so a reader scanning the registry185-// mid-write passes over it; it is renamed into place, so the name appears186-// whole or not at all; and then it is reopened from disk and validated, because187-// the file that will serve is the copy, and it is the copy's promises that188-// matter. A bundle that fails that check fails this build rather than somebody's189-// import.190-func install(191- o Options,192- manifest bundle.Manifest,193- parts []payloadParts,194- pyramids map[string]tiles.Pyramid,195- icons map[string][]byte,196- iconNames []string,197- target string,198-) error {199- file, err := os.CreateTemp(o.BundleDir, manifest.Volume.Slug+".atlas.tmp-*")200- if err != nil {201- return err103+func descriptorRelease(descriptor vnext.Descriptor) vnext.Release {104+ return vnext.Release{105+ Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,106+ Stamp: descriptor.Stamp, Worlds: descriptor.Worlds,202107 }203- defer os.Remove(file.Name())204- defer file.Close()108+}205109 206- writer, err := bundle.NewWriter(file, manifest)110+// WriteRegistryIndex writes a derived convenience listing. It has no authority:111+// deleting it is harmless because native release metadata and the directory112+// fold remain the registry.113+func WriteRegistryIndex(dir string) error {114+ descriptors, _, err := vnext.Scan(dir, vnext.StandardSchema())207115 if err != nil {208116 return err209117 }210- for _, part := range parts {211- if err := writer.AddDeflated(bundle.WorldEntryName(part.slug, bundle.WorldSuffix), part.detail); err != nil {212- return err213- }214- if err := writer.AddStored(bundle.WorldEntryName(part.slug, bundle.PackedSuffix), bytes.NewReader(part.packed)); err != nil {215- return err216- }217- if err := writer.AddDeflated(bundle.WorldEntryName(part.slug, bundle.TextSuffix), part.text); err != nil {218- return err219- }220- }221- locals := make([]string, 0, len(pyramids))222- for local := range pyramids {223- locals = append(locals, local)224- }225- sort.Strings(locals)226- for _, local := range locals {227- if err := addPyramid(writer, o.Tiles, pyramids[local], local); err != nil {228- return err229- }230- }231- for _, name := range iconNames {232- if err := writer.AddDeflated("icons/"+name, icons[name]); err != nil {233- return err118+ sort.Slice(descriptors, func(i, j int) bool {119+ if descriptors[i].Slug != descriptors[j].Slug {120+ return descriptors[i].Slug < descriptors[j].Slug234121 }122+ return vnext.Newer(descriptors[i], descriptors[j])123+ })124+ type build struct {125+ File string `json:"file"`126+ Slug string `json:"slug"`127+ Title string `json:"title"`128+ Stamp string `json:"stamp"`129+ CreatedAt string `json:"createdAt"`130+ Revision int `json:"revision,omitempty"`131+ Size int64 `json:"size"`132+ Worlds int `json:"worlds"`133+ }134+ listed := make([]build, 0, len(descriptors))135+ for _, descriptor := range descriptors {136+ listed = append(listed, build{137+ File: filepath.Base(descriptor.Locator), Slug: descriptor.Slug, Title: descriptor.Title,138+ Stamp: descriptor.Stamp, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,139+ Size: descriptor.Size, Worlds: descriptor.Worlds,140+ })235141 }236- if err := writer.Close(); err != nil {142+ data, err := json.Marshal(struct {143+ Builds []build `json:"builds"`144+ }{Builds: listed})145+ if err != nil {237146 return err238147 }239- if err := file.Close(); err != nil {148+ data = append(data, '\n')149+ stage, err := os.CreateTemp(dir, ".index-*")150+ if err != nil {240151 return err241152 }242- // CreateTemp opens private files; a bundle is an artefact anyone may copy.243- if err := os.Chmod(file.Name(), 0o644); err != nil {153+ name := stage.Name()154+ defer os.Remove(name)155+ if _, err := stage.Write(data); err != nil {156+ stage.Close()244157 return err245158 }246- if err := os.Rename(file.Name(), target); err != nil {159+ if err := stage.Close(); err != nil {247160 return err248161 }249-250- written, err := bundle.Open(target)251- if err != nil {252- return fmt.Errorf("reopen %s: %w", filepath.Base(target), err)253- }254- defer written.Close()255- if err := written.Validate(); err != nil {256- return fmt.Errorf("validate %s: %w", filepath.Base(target), err)257- }258- return nil162+ return os.Rename(name, filepath.Join(dir, "index.json"))259163 }260164 261-// addPyramid copies one derived pyramid's rasters into the bundle, stored262-// uncompressed so a reader serves them as straight byte ranges.263-func addPyramid(writer *bundle.Writer, set *tiles.Set, pyramid tiles.Pyramid, local string) error {264- list, err := set.Tiles(pyramid)265- if err != nil {266- return err165+func rasterBlobList(set *tiles.Set, pyramids map[string]tiles.Pyramid) ([]vnext.Blob, int, error) {166+ locals := make([]string, 0, len(pyramids))167+ for local := range pyramids {168+ locals = append(locals, local)267169 }268- for _, tile := range list {269- file, err := os.Open(tile.Path)170+ sort.Strings(locals)171+ var blobs []vnext.Blob172+ for _, local := range locals {173+ list, err := set.Tiles(pyramids[local])270174 if err != nil {271- return err175+ return nil, 0, err272176 }273- err = writer.AddStored("tiles/"+local+"/"+tile.Name, file)274- file.Close()275- if err != nil {276- return err177+ for _, tile := range list {178+ blobs = append(blobs, vnext.Blob{Name: "tiles/" + local + "/" + tile.Name, Path: tile.Path})277179 }278180 }279- return nil280-}281-282-// WriteRegistryIndex derives the registry listing from the files beside it. It283-// is always safe to delete: the next scan derives it again.284-func WriteRegistryIndex(dir string) error {285- candidates, skipped, err := bundle.Scan(dir)286- if err != nil {287- return err288- }289- _ = skipped290- return bundle.WriteIndex(dir, candidates)181+ return blobs, len(blobs), nil291182 }@@ -27,6 +27,7 @@ package main
2727 import (2828 "embed"2929 "fmt"30+ "io"3031 "io/fs"3132 "log/slog"3233 "os"@@ -36,7 +37,7 @@ import (
3637 "github.com/wailsapp/wails/v2/pkg/options"3738 "github.com/wailsapp/wails/v2/pkg/options/assetserver"3839 39- "github.com/FelineStateMachine/atlas/format/bundle"40+ "github.com/FelineStateMachine/atlas/format/vnext"4041 "github.com/FelineStateMachine/atlas/internal/app"4142 "github.com/FelineStateMachine/atlas/internal/app/hostenv/oshost"4243 "github.com/FelineStateMachine/atlas/internal/app/hostenv/wailshost"@@ -113,7 +114,7 @@ func run() error {
113114 }114115 // The included Earth build lands before the host is constructed, so the115116 // host's first scan of the library already sees it. Installation goes116- // through format/bundle's own path -- validated, versioned, staged,117+ // through format/vnext's own path -- validated, versioned, staged,117118 // idempotent -- so an already-installed build is a no-op and another Earth118119 // build in the library is left exactly where it is, side by side, for the119120 // registry fold to order. A built-in that cannot be installed is a startup@@ -142,6 +143,11 @@ func run() error {
142143 return err143144 }144145 handler := app.New(host, app.Options{Static: static})146+ window.Accept(func(name string, content io.Reader) error {147+ _, err := handler.Install(name, content)148+ return err149+ })150+ window.QueuePaths(os.Args[1:])145151 146152 slog.Info("opening", logging.Op("desktop"), logging.Path(library),147153 slog.Int("volumes", len(host.Volumes().Volumes())))@@ -155,6 +161,18 @@ func run() error {
155161 // is a redirect, so the host walks those itself (redirects.go).156162 AssetServer: &assetserver.Options{Handler: followRedirects(handler)},157163 OnStartup: window.Opened,164+ DragAndDrop: &options.DragAndDrop{165+ EnableFileDrop: true,166+ // The webview must never navigate to or download a dropped .atlas;167+ // Wails hands the native path to Window instead.168+ DisableWebViewDrop: true,169+ },170+ SingleInstanceLock: &options.SingleInstanceLock{171+ UniqueId: appIdentifier,172+ OnSecondInstanceLaunch: func(data options.SecondInstanceData) {173+ window.QueuePaths(data.Args)174+ },175+ },158176 // A map wants every pixel it can have, so the window opens filling the159177 // screen it lands on rather than at a size chosen here. Not fullscreen:160178 // the menu bar and the dock stay, and the window is still a window.@@ -181,12 +199,12 @@ func installIncluded(library string) error {
181199 if err != nil {182200 return fmt.Errorf("%s: %w", name, err)183201 }184- installed, err := bundle.InstallBytes(library, data)202+ installed, err := vnext.InstallBytes(library, data, vnext.StandardSchema())185203 if err != nil {186204 return fmt.Errorf("%s: %w", name, err)187205 }188206 slog.Info("included volume installed", logging.Op("desktop"),189- logging.Volume(installed.Slug), logging.Stamp(bundle.ShortStamp(installed.Stamp)),207+ logging.Volume(installed.Slug), logging.Stamp(vnext.ShortStamp(installed.Stamp)),190208 logging.Path(installed.Locator))191209 }192210 return nil@@ -1,15 +1,7 @@
11 package main22 3-// The included Earth volume, held to its promises: the committed bundle is a4-// valid, ordinary format-v3 volume; startup installs it through the format's5-// own registry rules; and the installed file serves through the same6-// application paths any imported bundle serves through. Every test here reads7-// the embedded asset -- the same bytes a release build ships -- so what is8-// judged is what a first launch actually opens.9-103 import (114 "bytes"12- "encoding/json"135 "image/jpeg"146 "io"157 "io/fs"@@ -20,15 +12,11 @@ import (
2012 "strings"2113 "testing"2214 23- "github.com/FelineStateMachine/atlas/format/bundle"24- "github.com/FelineStateMachine/atlas/format/semconv"15+ "github.com/FelineStateMachine/atlas/format/vnext"2516 "github.com/FelineStateMachine/atlas/internal/app"2617 "github.com/FelineStateMachine/atlas/internal/app/hostenv/oshost"2718 )2819 29-// includedEarth reads the one embedded bundle. There being exactly one is part30-// of the contract: the embed pattern would happily carry a stray second build,31-// and two included Earths would be two answers to what a first launch shows.3220 func includedEarth(t *testing.T) []byte {3321 t.Helper()3422 names, err := fs.Glob(included, "included/*.atlas")@@ -36,7 +24,7 @@ func includedEarth(t *testing.T) []byte {
3624 t.Fatal(err)3725 }3826 if len(names) != 1 {39- t.Fatalf("the executable embeds %d bundles %v, want exactly the one Earth", len(names), names)27+ t.Fatalf("embedded %d volumes, want one Earth", len(names))4028 }4129 data, err := included.ReadFile(names[0])4230 if err != nil {@@ -45,371 +33,143 @@ func includedEarth(t *testing.T) []byte {
4533 return data4634 }4735 48-func openIncluded(t *testing.T) *bundle.Reader {36+func openIncluded(t *testing.T) *vnext.Reader {4937 t.Helper()5038 data := includedEarth(t)51- reader, err := bundle.NewReader(bytes.NewReader(data), int64(len(data)))39+ reader, err := vnext.Open(bytes.NewReader(data), int64(len(data)), vnext.StandardSchema())5240 if err != nil {5341 t.Fatal(err)5442 }5543 return reader5644 }5745 58-// earthPayload is as much of the world payload as these tests read.59-type earthPayload struct {60- Lenses []struct {61- Name string `json:"name"`62- Tiles string `json:"tiles"`63- MinZoom int `json:"minZoom"`64- MaxZoom int `json:"maxZoom"`65- FullZoom int `json:"fullZoom"`66- SourceZoom int `json:"sourceZoom"`67- Formats []string `json:"formats"`68- Bounds *struct {69- X, Y, Width, Height int70- } `json:"bounds"`71- } `json:"lenses"`72- Collections []struct {73- Title string `json:"title"`74- Group string `json:"group"`75- Kind string `json:"kind"`76- IconAsset string `json:"iconAsset"`77- Attrs map[string]string `json:"attrs"`78- Features []json.RawMessage `json:"features"`79- } `json:"collections"`80- Attrs map[string]string `json:"attrs"`81- Merged []struct {82- Source string `json:"source"`83- Slug string `json:"slug"`84- Origin bool `json:"origin"`85- } `json:"merged"`86-}87-88-func readEarthPayload(t *testing.T, reader *bundle.Reader) earthPayload {89- t.Helper()90- data, err := reader.ReadEntry(bundle.WorldEntryName("earth", bundle.WorldSuffix))91- if err != nil {92- t.Fatal(err)93- }94- var payload earthPayload95- if err := json.Unmarshal(data, &payload); err != nil {96- t.Fatal(err)97- }98- return payload99-}100-101-// TestIncludedEarthIsAValidOrdinaryBundle: the committed built-in passes the102-// format's whole validation surface and says exactly what the included volume103-// is specified to say -- format 3, conventions 2, volume earth, one world104-// earth, raster-only.105-func TestIncludedEarthIsAValidOrdinaryBundle(t *testing.T) {46+func TestIncludedEarthIsNativeAndSemantic(t *testing.T) {10647 reader := openIncluded(t)10748 if err := reader.Validate(); err != nil {108- t.Fatalf("the committed built-in does not validate: %v", err)109- }110- m := reader.Manifest111- if m.FormatVersion != 3 || m.Conventions != 2 {112- t.Errorf("format %d conventions %d, want 3 and 2", m.FormatVersion, m.Conventions)113- }114- if m.Volume.Slug != "earth" || m.Volume.Title != "Earth" {115- t.Errorf("volume %s/%s", m.Volume.Slug, m.Volume.Title)116- }117- if len(m.Worlds) != 1 || m.Worlds[0].Slug != "earth" || m.Worlds[0].Title != "Earth" {118- t.Fatalf("worlds %+v, want exactly the one earth", m.Worlds)119- }120- if m.Worlds[0].Points != 202 || m.Worlds[0].Paths != 0 || m.Worlds[0].Areas != 177 {121- t.Errorf("the manifest counts %+v; the demo carries 202 capitals and 177 countries", m.Worlds[0])122- }123- if m.Version.CreatedAt != "2026-08-03T16:21:07Z" {124- t.Errorf("createdAt %s is not the pinned capture time", m.Version.CreatedAt)125- }126-}127-128-// TestIncludedEarthDeclaresTheWholeSphere: one Blue Marble lens over the129-// complete 8192×4096 Earth surface, the demo's capitals and countries, and130-// exactly the spherical equirectangular declarations the analysis contracts131-// recognise.132-func TestIncludedEarthDeclaresTheWholeSphere(t *testing.T) {133- reader := openIncluded(t)134- payload := readEarthPayload(t, reader)135-136- if len(payload.Collections) != 7 {137- t.Fatalf("%d collections, want six continents of capitals and the countries", len(payload.Collections))138- }139- for _, held := range payload.Collections[:6] {140- if held.Group != "Capitals" || held.Kind != "point" || held.IconAsset == "" {141- t.Errorf("capitals collection %q arrives as %s/%s with icon %q",142- held.Title, held.Group, held.Kind, held.IconAsset)143- }144- }145- countries := payload.Collections[6]146- if countries.Title != "Countries" || countries.Kind != "area" ||147- countries.Attrs[semconv.KeyLabelPolicy] != semconv.LabelQuiet || len(countries.Features) != 177 {148- t.Errorf("the ground arrives as %q %s (%d features, %v)",149- countries.Title, countries.Kind, len(countries.Features), countries.Attrs)150- }151- if len(payload.Lenses) != 1 {152- t.Fatalf("%d lenses, want the one Blue Marble", len(payload.Lenses))49+ t.Fatal(err)15350 }154- lens := payload.Lenses[0]155- if lens.Name != "Blue Marble" {156- t.Errorf("lens %q", lens.Name)51+ if reader.VolumeID != "earth" || reader.Release.Title != "Earth" || reader.Release.CreatedAt != "2026-08-03T16:21:07Z" {52+ t.Fatalf("identity/release = %s %#v", reader.VolumeID, reader.Release)15753 }158- if lens.MinZoom != 0 || lens.MaxZoom != 6 || lens.FullZoom != 6 || lens.SourceZoom != 6 {159- t.Errorf("zooms min %d max %d full %d source %d, want 0/6/6/6",160- lens.MinZoom, lens.MaxZoom, lens.FullZoom, lens.SourceZoom)54+ volume, err := reader.Volume()55+ if err != nil {56+ t.Fatal(err)16157 }162- if len(lens.Formats) != 7 {163- t.Errorf("%d formats for 7 levels", len(lens.Formats))58+ if len(volume.Worlds) != 1 || volume.Worlds[0].ID != "earth" {59+ t.Fatalf("worlds = %#v", volume.Worlds)16460 }165- for _, format := range lens.Formats {166- if format != "jpg" {167- t.Errorf("a level is %q, want jpg throughout", format)61+ points, areas := 0, 062+ for _, set := range volume.Worlds[0].FeatureSets {63+ for _, feature := range set.Features {64+ switch feature.Geometry.Kind {65+ case vnext.GeometryPoint:66+ points++67+ case vnext.GeometryPolygon:68+ areas++69+ }16870 }16971 }170- if lens.Bounds == nil || *lens.Bounds != struct{ X, Y, Width, Height int }{0, 0, 8192, 4096} {171- t.Errorf("bounds %+v, want the complete 0,0,8192,4096 surface", lens.Bounds)72+ if points != 202 || areas != 177 {73+ t.Fatalf("Earth holds %d points and %d areas", points, areas)17274 }173-174- want := map[string]string{175- semconv.KeyIconOutset: "dark",176- semconv.KeyGeometrySurface: "sphere",177- semconv.KeyGeometryProjection: "equirect",178- semconv.KeyGeometryEquirectPx: "0,0,8192,4096",179- semconv.KeyGeometryEquirectDeg: "-180,90,180,-90",180- semconv.KeyGeometryBody: "earth",181- semconv.KeyGeometryRadiusKM: "6371.0088",182- }183- if len(payload.Attrs) != len(want) {184- t.Errorf("the world carries %d attributes %v, want %d", len(payload.Attrs), payload.Attrs, len(want))185- }186- for key, value := range want {187- if payload.Attrs[key] != value {188- t.Errorf("world says %s=%q, want %q", key, payload.Attrs[key], value)189- }190- }191-192- if len(payload.Merged) != 1 || !payload.Merged[0].Origin ||193- payload.Merged[0].Source != "NASA Earth Observatory" ||194- payload.Merged[0].Slug != "nasa-blue-marble" {195- t.Errorf("provenance ledger %+v; the credit is part of the artifact", payload.Merged)75+ if len(reader.TableNames()) != 16 {76+ t.Fatalf("native bundle has %d typed tables", len(reader.TableNames()))19677 }19778 }19879 199-// TestIncludedEarthPyramidOpensAtEveryLevel: every advertised level holds its200-// whole half-height window of stored, decodable JPEG tiles.20180 func TestIncludedEarthPyramidOpensAtEveryLevel(t *testing.T) {20281 reader := openIncluded(t)20382 perLevel := map[string]int{}204- for _, name := range reader.Names() {83+ for _, name := range reader.BlobNames() {20584 if !strings.HasPrefix(name, "tiles/earth/") {20685 continue20786 }20887 parts := strings.Split(name, "/")20988 perLevel[parts[2]]++210- if !reader.Stored(name) {211- t.Fatalf("%s is compressed; format v3 stores tiles", name)212- }213- }214- wantPerLevel := map[string]int{"0": 1, "1": 2, "2": 8, "3": 32, "4": 128, "5": 512, "6": 2048}215- for level, want := range wantPerLevel {216- if perLevel[level] != want {217- t.Errorf("level %s holds %d tiles, want %d", level, perLevel[level], want)218- }21989 }220- // One tile of every level decodes as the JPEG its name claims.221- for _, name := range []string{222- "tiles/earth/0/0/0.jpg", "tiles/earth/1/1/0.jpg", "tiles/earth/2/3/1.jpg",223- "tiles/earth/3/7/3.jpg", "tiles/earth/4/15/7.jpg", "tiles/earth/5/31/15.jpg",224- "tiles/earth/6/63/31.jpg",225- } {226- data, err := reader.ReadEntry(name)227- if err != nil {228- t.Fatalf("%s: %v", name, err)229- }230- if _, err := jpeg.Decode(bytes.NewReader(data)); err != nil {231- t.Fatalf("%s does not decode: %v", name, err)90+ want := map[string]int{"0": 1, "1": 2, "2": 8, "3": 32, "4": 128, "5": 512, "6": 2048}91+ for level, count := range want {92+ if perLevel[level] != count {93+ t.Errorf("level %s has %d tiles, want %d", level, perLevel[level], count)23294 }23395 }234- if !reader.Stored(bundle.WorldEntryName("earth", bundle.PackedSuffix)) {235- t.Error("the packed payload is compressed; format v3 stores it")236- }237-}238-239-// TestIncludedEarthCarriesNoRuntimeURL is offline purity, asserted directly on240-// the shipped bytes over and above Validate's own scan.241-func TestIncludedEarthCarriesNoRuntimeURL(t *testing.T) {242- reader := openIncluded(t)243- for _, name := range []string{244- bundle.WorldEntryName("earth", bundle.WorldSuffix),245- bundle.WorldEntryName("earth", bundle.TextSuffix),246- } {247- data, err := reader.ReadEntry(name)96+ for _, name := range []string{"tiles/earth/0/0/0.jpg", "tiles/earth/6/63/31.jpg"} {97+ data, err := reader.Blob(name)24898 if err != nil {24999 t.Fatal(err)250100 }251- for _, scheme := range []string{"http://", "https://"} {252- if bytes.Contains(data, []byte(scheme)) {253- t.Errorf("%s carries %q", name, scheme)254- }101+ if _, err := jpeg.Decode(bytes.NewReader(data)); err != nil {102+ t.Fatalf("%s: %v", name, err)255103 }256104 }257105 }258106 259-// TestInstallIncludedIsIdempotentAndOverwritesNothing: a first launch installs260-// one versioned Earth file; a second launch changes nothing; and a distinct261-// Earth build already in the library stays exactly where it is, side by side.262-func TestInstallIncludedIsIdempotentAndOverwritesNothing(t *testing.T) {107+func TestInstallIncludedIsIdempotentAndPreservesOtherBuilds(t *testing.T) {263108 library := t.TempDir()264109 if err := installIncluded(library); err != nil {265110 t.Fatal(err)266111 }267- entries, err := os.ReadDir(library)268- if err != nil {269- t.Fatal(err)270- }112+ entries, _ := os.ReadDir(library)271113 if len(entries) != 1 {272- t.Fatalf("a first launch installed %d files", len(entries))114+ t.Fatalf("first install wrote %d files", len(entries))273115 }274116 installed := filepath.Join(library, entries[0].Name())275- if !strings.HasPrefix(entries[0].Name(), "earth-20260803-") || !strings.HasSuffix(entries[0].Name(), ".atlas") {276- t.Errorf("installed as %s, want the versioned earth name", entries[0].Name())277- }278- if _, err := bundle.Describe(installed); err != nil {279- t.Fatalf("the installed file does not describe: %v", err)280- }281- before, err := os.Stat(installed)282- if err != nil {283- t.Fatal(err)284- }285-117+ before, _ := os.Stat(installed)286118 if err := installIncluded(library); err != nil {287119 t.Fatal(err)288120 }289- entries, err = os.ReadDir(library)290- if err != nil {291- t.Fatal(err)292- }293- if len(entries) != 1 {294- t.Fatalf("a second launch grew the library to %d files", len(entries))295- }296- after, err := os.Stat(installed)297- if err != nil {298- t.Fatal(err)299- }121+ after, _ := os.Stat(installed)300122 if !after.ModTime().Equal(before.ModTime()) || after.Size() != before.Size() {301- t.Error("a second launch touched the installed file")123+ t.Fatal("idempotent install touched file")302124 }303-304- // A distinct Earth build -- another capture day, another stamp -- is not305- // overwritten and not deleted; the registry fold decides which one serves.306125 other := writeDistinctEarth(t, library)307126 if err := installIncluded(library); err != nil {308127 t.Fatal(err)309128 }310129 if _, err := os.Stat(other); err != nil {311- t.Errorf("a launch removed the other Earth build: %v", err)312- }313- entries, err = os.ReadDir(library)314- if err != nil {315- t.Fatal(err)130+ t.Fatal("other build was removed")316131 }132+ entries, _ = os.ReadDir(library)317133 if len(entries) != 2 {318- t.Errorf("the library holds %d files; two Earth builds sit side by side", len(entries))134+ t.Fatalf("library holds %d files, want two builds", len(entries))319135 }320136 }321137 322-// writeDistinctEarth states a second, minimal Earth build through the format's323-// real writer, dated after the included one so the fold prefers it.324138 func writeDistinctEarth(t *testing.T, dir string) string {325139 t.Helper()326- manifest := bundle.Manifest{327- Format: bundle.Format,328- FormatVersion: bundle.FormatVersion,329- Volume: bundle.Volume{Slug: "earth", Title: "Earth"},330- Version: bundle.Version{Stamp: "ffff", CreatedAt: "2026-09-01T00:00:00Z"},331- TileGrid: bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},332- Worlds: []bundle.WorldEntry{{333- Slug: "flat", Title: "Flat", UpdatedAt: "2026-09-01T00:00:00Z",334- }},335- }336- path := filepath.Join(dir, bundle.VersionedFileName(manifest))337- file, err := os.Create(path)140+ volume := vnext.Volume{ID: "earth", Title: "Earth", Worlds: []vnext.World{{141+ ID: "flat", Title: "Flat", CoordinateSpace: vnext.CoordinateSpace{ID: "flat-space", Kind: "synthetic", Unit: "pixel", Definition: "atlas:plane", Extent: [4]float64{0, 0, 1, 1}},142+ Presentation: vnext.Presentation{ID: "default", Title: "Default"},143+ }}}144+ packed, err := vnext.Compile(volume)338145 if err != nil {339146 t.Fatal(err)340147 }341- writer, err := bundle.NewWriter(file, manifest)148+ packed.Release.CreatedAt = "2026-09-01T00:00:00Z"149+ temporary := filepath.Join(dir, "other.tmp")150+ file, err := os.Create(temporary)342151 if err != nil {343152 t.Fatal(err)344153 }345- if err := writer.AddDeflated("worlds/flat.json",346- []byte(`{"lenses":[{"name":"Flat","tiles":"flat","minZoom":0,"maxZoom":0,"fullZoom":0,"sourceZoom":0,"formats":["jpg"],"interpolate":true}],"collections":[]}`)); err != nil {347- t.Fatal(err)348- }349- if err := writer.AddStored("worlds/flat.bin", bytes.NewReader(bundle.PackLocations(nil))); err != nil {350- t.Fatal(err)351- }352- if err := writer.AddDeflated("worlds/flat.text", []byte(`{}`)); err != nil {353- t.Fatal(err)354- }355- if err := writer.AddStored("tiles/flat/0/0/0.jpg", bytes.NewReader(statedJPEG(t))); err != nil {356- t.Fatal(err)357- }358- if err := writer.Close(); err != nil {154+ if err := vnext.Write(file, packed); err != nil {155+ file.Close()359156 t.Fatal(err)360157 }361158 if err := file.Close(); err != nil {362159 t.Fatal(err)363160 }364- return path365-}366-367-func statedJPEG(t *testing.T) []byte {368- t.Helper()369- reader := openIncluded(t)370- data, err := reader.ReadEntry("tiles/earth/0/0/0.jpg")161+ descriptor, err := vnext.Describe(temporary, vnext.StandardSchema())371162 if err != nil {372163 t.Fatal(err)373164 }374- return data375-}376-377-// TestDesktopStartupExposesEarthOnTheFirstScan walks the shell's own startup378-// order over a temporary empty data directory: library created, built-in379-// installed, host constructed -- and the host's first scan already serves380-// Earth, with no network anywhere in the path.381-func TestDesktopStartupExposesEarthOnTheFirstScan(t *testing.T) {382- data := t.TempDir()383- library := filepath.Join(data, "bundles")384- if err := os.MkdirAll(library, 0o755); err != nil {385- t.Fatal(err)386- }387- if err := installIncluded(library); err != nil {388- t.Fatal(err)389- }390- host, err := oshost.New(oshost.Options{391- BundlesDir: library,392- SessionsDir: filepath.Join(data, "sessions"),393- })394- if err != nil {165+ target := filepath.Join(dir, vnext.VersionedFileName(descriptor.Slug, vnext.Release{Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision, Stamp: descriptor.Stamp, Worlds: descriptor.Worlds}))166+ if err := os.Rename(temporary, target); err != nil {395167 t.Fatal(err)396168 }397- t.Cleanup(func() { host.Volumes().(interface{ Close() error }).Close() })398-399- volumes := host.Volumes().Volumes()400- if len(volumes) != 1 {401- t.Fatalf("the first scan lists %d volumes", len(volumes))402- }403- manifest := volumes[0].Manifest()404- if manifest.Volume.Slug != "earth" || manifest.Volume.Title != "Earth" {405- t.Errorf("the first scan serves %s/%s", manifest.Volume.Slug, manifest.Volume.Title)406- }169+ return target407170 }408171 409-// TestEarthServesThroughTheApplication: the installed file -- never the410-// embedded copy -- answers the application's own data plane: the catalog lists411-// Earth, the world page opens, and a raster tile reads back as the JPEG it is.412-func TestEarthServesThroughTheApplication(t *testing.T) {172+func TestDesktopStartupAndApplicationServeNativeEarth(t *testing.T) {413173 data := t.TempDir()414174 library := filepath.Join(data, "bundles")415175 if err := os.MkdirAll(library, 0o755); err != nil {@@ -418,32 +178,37 @@ func TestEarthServesThroughTheApplication(t *testing.T) {
418178 if err := installIncluded(library); err != nil {419179 t.Fatal(err)420180 }421- host, err := oshost.New(oshost.Options{422- BundlesDir: library,423- SessionsDir: filepath.Join(data, "sessions"),424- })181+ host, err := oshost.New(oshost.Options{BundlesDir: library, SessionsDir: filepath.Join(data, "sessions")})425182 if err != nil {426183 t.Fatal(err)427184 }428- t.Cleanup(func() { host.Volumes().(interface{ Close() error }).Close() })429- handler := app.New(host, app.Options{})430- server := httptest.NewServer(handler)185+ t.Cleanup(func() { _ = host.Volumes().(interface{ Close() error }).Close() })186+ volumes := host.Volumes().Volumes()187+ if len(volumes) != 1 || volumes[0].Info().Slug != "earth" {188+ t.Fatalf("startup volumes = %#v", volumes)189+ }190+ server := httptest.NewServer(app.New(host, app.Options{}))431191 defer server.Close()432-433192 catalog := get(t, server.Client(), server.URL+"/data/catalog.json")434193 if !strings.Contains(string(catalog), `"earth"`) {435- t.Errorf("the catalog does not list earth: %s", catalog)194+ t.Fatalf("catalog = %s", catalog)436195 }437-438196 page := get(t, server.Client(), server.URL+"/v/earth/earth")439197 if !strings.Contains(string(page), "Earth") {440- t.Error("the world page does not open on Earth")198+ t.Fatal("Earth page did not open")199+ }200+ stamp := vnext.ShortStamp(volumes[0].Info().Stamp)201+ schema := get(t, server.Client(), server.URL+"/data/v/earth/"+stamp+"/schema.json")202+ if !bytes.Contains(schema, []byte(`"types"`)) {203+ t.Fatal("native schema endpoint is not canonical schema JSON")204+ }205+ features := get(t, server.Client(), server.URL+"/data/v/earth/"+stamp+"/data/features.pack")206+ if !bytes.HasPrefix(features, []byte("ATLASPK\x00")) {207+ t.Fatal("native feature table endpoint has no typed-block framing")441208 }442-443- stamp := bundle.ShortStamp(host.Volumes().Volumes()[0].Manifest().Version.Stamp)444209 tile := get(t, server.Client(), server.URL+"/data/v/earth/"+stamp+"/tiles/earth/0/0/0.jpg")445210 if _, err := jpeg.Decode(bytes.NewReader(tile)); err != nil {446- t.Fatalf("the served tile does not decode: %v", err)211+ t.Fatal(err)447212 }448213 }449214 @@ -18,6 +18,7 @@ import (
1818 "path/filepath"1919 2020 "github.com/FelineStateMachine/atlas/format/bundle"21+ "github.com/FelineStateMachine/atlas/format/vnext"2122 )2223 2324 // Root answers the corpus bundles directory, relative to some caller-known@@ -50,13 +51,13 @@ func Pack(fixture, dir string) (string, error) {
5051 return "", err5152 }5253 53- path := filepath.Join(dir, bundle.VersionedFileName(manifest))54- file, err := os.Create(path)54+ legacy, err := os.CreateTemp(dir, ".corpus-v3-*")5555 if err != nil {5656 return "", err5757 }58- defer file.Close()59- writer, err := bundle.NewWriter(file, manifest)58+ legacyPath := legacy.Name()59+ defer os.Remove(legacyPath)60+ writer, err := bundle.NewWriter(legacy, manifest)6061 if err != nil {6162 return "", err6263 }@@ -87,6 +88,46 @@ func Pack(fixture, dir string) (string, error) {
8788 if err := writer.Close(); err != nil {8889 return "", err8990 }91+ if err := legacy.Close(); err != nil {92+ return "", err93+ }94+ opened, err := bundle.Open(legacyPath)95+ if err != nil {96+ return "", err97+ }98+ semantic, err := vnext.ImportV3Volume(manifest, opened.ReadEntry)99+ opened.Close()100+ if err != nil {101+ return "", err102+ }103+ packed, err := vnext.Compile(semantic)104+ if err != nil {105+ return "", err106+ }107+ packed.Release.CreatedAt = manifest.Version.CreatedAt108+ packed.Release.Revision = manifest.Version.Revision109+ native, err := os.CreateTemp(dir, ".corpus-native-*")110+ if err != nil {111+ return "", err112+ }113+ nativePath := native.Name()114+ defer os.Remove(nativePath)115+ if err := vnext.Write(native, packed); err != nil {116+ native.Close()117+ return "", err118+ }119+ if err := native.Close(); err != nil {120+ return "", err121+ }122+ descriptor, err := vnext.Describe(nativePath, vnext.StandardSchema())123+ if err != nil {124+ return "", err125+ }126+ release := vnext.Release{Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision, Stamp: descriptor.Stamp, Worlds: descriptor.Worlds}127+ path := filepath.Join(dir, vnext.VersionedFileName(descriptor.Slug, release))128+ if err := os.Rename(nativePath, path); err != nil {129+ return "", err130+ }90131 return path, nil91132 }92133 @@ -13,7 +13,7 @@ import (
1313 "os"1414 "path/filepath"1515 16- "github.com/FelineStateMachine/atlas/format/bundle"16+ "github.com/FelineStateMachine/atlas/format/vnext"1717 "github.com/FelineStateMachine/atlas/tests/corpus"1818 )1919 @@ -60,7 +60,7 @@ func run() error {
6060 if len(included) != 1 {6161 return fmt.Errorf("included/ holds %d bundles, want exactly the one Earth", len(included))6262 }63- installed, err := bundle.Install(dir, included[0])63+ installed, err := vnext.Install(dir, included[0], vnext.StandardSchema())6464 if err != nil {6565 return fmt.Errorf("installing %s: %w", included[0], err)6666 }@@ -15,7 +15,6 @@
1515 package island1616 1717 import (18- "bytes"1918 "context"2019 "encoding/json"2120 "io"@@ -28,6 +27,7 @@ import (
2827 "testing"2928 3029 "github.com/FelineStateMachine/atlas/format/bundle"30+ "github.com/FelineStateMachine/atlas/format/vnext"3131 "github.com/FelineStateMachine/atlas/internal/app"3232 "github.com/FelineStateMachine/atlas/internal/app/hostenv"3333 )@@ -176,20 +176,39 @@ type corpusBundle struct {
176176 entries map[string][]byte177177 }178178 179-func (v *corpusBundle) Manifest() bundle.Manifest { return v.manifest }179+func (v *corpusBundle) Info() hostenv.VolumeInfo {180+ manifest := v.manifest181+ info := hostenv.VolumeInfo{182+ Slug: manifest.Volume.Slug, Title: manifest.Volume.Title, Stamp: manifest.Version.Stamp,183+ Release: vnext.Release{Title: manifest.Volume.Title, CreatedAt: manifest.Version.CreatedAt, Revision: manifest.Version.Revision, Stamp: manifest.Version.Stamp, Worlds: len(manifest.Worlds)},184+ TileGrid: hostenv.TileGrid{SourceZoom: manifest.TileGrid.SourceZoom, FirstTile: manifest.TileGrid.FirstTile, TileSize: manifest.TileGrid.TileSize, Size: manifest.TileGrid.Size},185+ }186+ for _, world := range manifest.Worlds {187+ info.Worlds = append(info.Worlds, hostenv.WorldInfo{Slug: world.Slug, Title: world.Title, Parent: world.Parent, Points: world.Points, Paths: world.Paths, Areas: world.Areas, UpdatedAt: world.UpdatedAt})188+ }189+ return info190+}180191 181-func (v *corpusBundle) Open(name string) (io.ReadCloser, int64, error) {192+func (v *corpusBundle) Semantic() vnext.Volume {193+ semantic, _ := vnext.ImportV3Volume(v.manifest, func(name string) ([]byte, error) { return v.Blob(name) })194+ return semantic195+}196+197+func (v *corpusBundle) Blob(name string) ([]byte, error) {182198 held, ok := v.entries[name]183199 if !ok {184- return nil, 0, os.ErrNotExist200+ return nil, os.ErrNotExist185201 }186- return io.NopCloser(bytes.NewReader(held)), int64(len(held)), nil202+ return held, nil187203 }188204 205+func (v *corpusBundle) Schema() ([]byte, error) { return vnext.StandardSchema().Canonical() }206+func (v *corpusBundle) TableBlock(string) ([]byte, error) { return nil, os.ErrNotExist }207+189208 // firstWorld is the world a volume opens on when nobody has been anywhere.190209 func firstWorld(t *testing.T, volume hostenv.Volume) string {191210 t.Helper()192- worlds := volume.Manifest().Worlds211+ worlds := volume.Info().Worlds193212 if len(worlds) == 0 {194213 t.Fatal("the corpus volume holds no worlds")195214 }@@ -1,35 +1,14 @@
1-// Command corpussmoke walks a real library of .atlas files and reports which2-// of them parse and validate.3-//4-// It is deliberately NOT a CI gate and never will be: it reads an installed5-// library — the maintainer's, typically tens of gigabytes — that no fresh6-// checkout has. CI's rule is that every required test's inputs are committed7-// or built by the run; this command is the other half of the bargain, the8-// deep check a maintainer runs by hand over data that cannot be committed.9-//10-// It asserts invariants only: the container opens, the manifest re-reads,11-// validation passes, every world's packed locations unpack. It compares no12-// stamps, no hashes and no content — the days of holding the tree to captured13-// bytes from a private library are over.14-//15-// Usage:16-//17-// atlas-corpussmoke [-bundles dir]18-// make corpus-smoke19-//20-// The library is the -bundles flag, else $ATLAS_BUNDLES_DIR, else the21-// application's own data directory — the same resolution `atlas serve` uses.1+// Command corpussmoke fully validates every native .atlas file in a real2+// library. Legacy files are failures after the hard cutover.223 package main234 245 import (25- "archive/zip"26- "encoding/json"276 "flag"287 "fmt"298 "os"309 "path/filepath"3110 32- "github.com/FelineStateMachine/atlas/format/bundle"11+ "github.com/FelineStateMachine/atlas/format/vnext"3312 )3413 3514 const (@@ -40,7 +19,6 @@ const (
4019 func main() {4120 bundles := flag.String("bundles", "", "the library of .atlas files (default $"+bundlesDirEnv+", else the application's own data directory)")4221 flag.Parse()43-4422 dir, err := libraryDir(*bundles)4523 if err != nil {4624 fmt.Fprintln(os.Stderr, "corpussmoke:", err)@@ -64,77 +42,25 @@ func smoke(dir string) (failed int, err error) {
6442 if len(paths) == 0 {6543 return 0, fmt.Errorf("%s holds no .atlas files", dir)6644 }67- older := 06845 for _, path := range paths {69- problem := check(path)70- switch {71- case problem == nil:72- fmt.Printf("ok %s\n", filepath.Base(path))73- case isOlderFormat(path):74- // A library accretes: builds from before this format version sit75- // beside current ones, and a v%d reader refusing them is the76- // reader doing its job, not a finding about this tree.77- older++78- default:46+ if problem := check(path); problem != nil {7947 failed++8048 fmt.Printf("FAIL %s\n %v\n", filepath.Base(path), problem)49+ } else {50+ fmt.Printf("ok %s\n", filepath.Base(path))8151 }8252 }83- fmt.Printf("%d bundles: %d ok, %d failed, %d older than format v%d\n",84- len(paths), len(paths)-failed-older, failed, older, bundle.FormatVersion)53+ fmt.Printf("%d native bundles: %d ok, %d failed\n", len(paths), len(paths)-failed, failed)8554 return failed, nil8655 }8756 8857 func check(path string) error {89- reader, err := bundle.Open(path)58+ reader, err := vnext.OpenFile(path, vnext.StandardSchema())9059 if err != nil {9160 return err9261 }9362 defer reader.Close()94- if err := reader.Validate(); err != nil {95- return err96- }97- for _, world := range reader.Manifest.Worlds {98- if _, err := reader.Locations(world.Slug); err != nil {99- return fmt.Errorf("world %s: %w", world.Slug, err)100- }101- }102- return nil103-}104-105-// isOlderFormat peeks at the raw container alone — straight through106-// archive/zip, past the reader's version refusal — because a bundle that107-// says it is an earlier format is old, not broken. Format 1 announces itself108-// by layout: its manifest is atlas.json, a name this format retired. A109-// manifest.json that carries an earlier formatVersion is the same answer110-// said in the current layout.111-func isOlderFormat(path string) bool {112- archive, err := zip.OpenReader(path)113- if err != nil {114- return false115- }116- defer archive.Close()117- names := make(map[string]*zip.File, len(archive.File))118- for _, file := range archive.File {119- names[file.Name] = file120- }121- manifest, current := names["manifest.json"]122- if !current {123- _, v1 := names["atlas.json"]124- return v1125- }126- entry, err := manifest.Open()127- if err != nil {128- return false129- }130- defer entry.Close()131- var peek struct {132- FormatVersion int `json:"formatVersion"`133- }134- if err := json.NewDecoder(entry).Decode(&peek); err != nil {135- return false136- }137- return peek.FormatVersion > 0 && peek.FormatVersion < bundle.FormatVersion63+ return reader.Validate()13864 }13965 14066 func libraryDir(flagged string) (string, error) {