clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / format / bundle / bundle.go
bundle.go 9.3K
1// Package bundle reads and writes the .atlas file: one volume, complete, in2// one zip archive.3//4// A volume is the subject of an atlas -- a game, a planet, a city -- and its5// bundle carries everything a reader needs to draw it: world payloads, tile6// pyramids, icons, behind a manifest naming what is inside. A reader can list7// a volume without reading past the manifest and can serve it without8// unpacking anything to disk.9//10// The vocabulary is the format's own. A volume holds worlds -- the distinct11// grounds a reader can stand on, which for a game are its maps and for a12// captured city are its dated revisions -- and each world is pictured by one13// or more lenses. Collections are the ordered groups of features within a14// world; every feature is exactly one of point, path, or area. Nothing in the15// format records where a volume's data was captured from: a producer16// flattens whatever its source looks like into this shape, and a reader only17// ever sees this shape.18//19// The container's layout, byte formats, and invariants are specified in20// docs/format.md, which is written to be sufficient on its own. Three21// invariants govern the package:22//23// - Determinism. Same archive in, same [Stamp] out, same file name, on any24// machine. See [Stamp] and [VersionedFileName].25// - Offline purity. No runtime URL anywhere in a payload; a bundle serves26// with zero network, forever. See [Reader.Validate].27// - Asymmetric conventions. Producers are strict and readers are lenient28// about attribute vocabulary; readers refuse unknown *format versions*29// outright. See [Manifest.Validate] and format/semconv.30//31// The package depends on the Go standard library alone. A third party who32// knows nothing of Atlas the application can import it and read a bundle.33package bundle3435import (36 "fmt"37 "strings"38)3940const (41 // Format names the container in the manifest, so a stray zip renamed to42 // .atlas is refused by name rather than by whatever fails first.43 Format = "atlas-bundle"4445 // FormatVersion is bumped when the layout changes shape. A reader refuses46 // versions it does not know rather than guessing at them.47 //48 // Version 2 renamed the format's concepts -- game to volume, map to world,49 // variant to lens -- through the manifest and payload keys alike. Version50 // 3 unified what a world holds: the payload's groups, zones, and pins51 // became one collections array of point, path, and area features, and the52 // manifest counts each kind instead of counting only pins.53 FormatVersion = 35455 // ManifestName is the entry the whole bundle is read through. A writer56 // places it first in the archive so a listing shows it first, though a57 // reader finds it by name wherever it sits.58 ManifestName = "atlas.json"5960 // WorldsPrefix, TilesPrefix, and IconsPrefix are the three trees a bundle61 // holds beside its manifest.62 WorldsPrefix = "worlds/"63 TilesPrefix = "tiles/"64 IconsPrefix = "icons/"65)6667// The three entries one world's payload splits into. Each world's parts are68// named for its slug: worlds/<slug>.json, .bin, and .text.69const (70 // WorldSuffix names the world payload proper: lenses, collections,71 // inline shape geometry, attributes, merge ledger. Read when the world72 // opens.73 WorldSuffix = ".json"74 // PackedSuffix names the ATLASLOC packed point locations. Read when the75 // world opens; see [PackLocations].76 PackedSuffix = ".bin"77 // TextSuffix names the per-feature prose, links, and attributes, keyed by78 // feature id. Read one feature at a time, when a card opens.79 TextSuffix = ".text"80)8182// WorldEntryName is the archive entry holding one part of a world's payload:83// WorldEntryName("overworld", WorldSuffix) is "worlds/overworld.json".84func WorldEntryName(slug, suffix string) string {85 return WorldsPrefix + slug + suffix86}8788// Manifest is what a bundle says about itself: which volume it is, which89// build of that volume's data it carries, and which worlds are inside.90//91// The JSON encoding of this struct is normative. Its field order, its tags,92// and which fields are omitted when empty all feed the stamp, so a change93// here changes every stamp -- which is why the shape is frozen with the94// format version.95type Manifest struct {96 Format string `json:"format"`97 FormatVersion int `json:"formatVersion"`98 Conventions int `json:"conventions,omitempty"`99 Volume Volume `json:"volume"`100 Version Version `json:"version"`101 TileGrid TileGrid `json:"tileGrid"`102 Worlds []WorldEntry `json:"worlds"`103}104105// Volume is the identity a bundle claims. The slug is the identity proper:106// two bundles naming the same slug are two builds of the same volume, however107// their files are named.108type Volume struct {109 Slug string `json:"slug"`110 Title string `json:"title"`111}112113// Version distinguishes builds of the same volume. The stamp is a content114// fingerprint -- two bundles with equal stamps hold the same data -- and115// CreatedAt orders them, so a newer capture dropped beside an older one wins.116// Revision orders builds of the same capture: the data has not moved, but the117// policy that turns a capture into a bundle has, and the build under the newer118// policy is the one that should serve. A bundle from before the field existed119// reads as revision zero and loses to any revised build.120//121// CreatedAt is the newest capture time across the volume's worlds, never the122// build time. That is what lets the same archive built anywhere, any time,123// carry the same version and the same file name.124type Version struct {125 Stamp string `json:"stamp"`126 CreatedAt string `json:"createdAt"`127 Revision int `json:"revision,omitempty"`128}129130// TileGrid is the window this volume's worlds are cut from: the zoom the131// source was captured at, where the first tile sits, and how big a tile and132// the world square are. A world cut from a window of its own overrides part of133// this in its own payload.134type TileGrid struct {135 SourceZoom int `json:"sourceZoom"`136 FirstTile int `json:"firstTile"`137 TileSize int `json:"tileSize"`138 Size int `json:"size"`139}140141// Coordinate is a point in world space, in the latitude and longitude of the142// volume's own projection.143type Coordinate struct {144 Lat float64 `json:"lat"`145 Lng float64 `json:"lng"`146}147148// WorldEntry lists one world: enough to offer it and to open it, and nothing149// that only matters once it is open. The slug names the world's payload150// entries and is the world's identity everywhere.151//152// Points, Paths, and Areas count the world's features by kind -- every153// feature is exactly one of the three -- so a listing can say how much a154// world holds without opening its payload, and validation can hold the155// payload to the promise.156type WorldEntry struct {157 Slug string `json:"slug"`158 Title string `json:"title"`159 Parent string `json:"parent,omitempty"`160 IconOutset string `json:"iconOutset,omitempty"`161 Center Coordinate `json:"center"`162 Points int `json:"points"`163 Paths int `json:"paths"`164 Areas int `json:"areas"`165 UpdatedAt string `json:"updatedAt"`166}167168// Features counts everything the world holds, across the three kinds.169func (e WorldEntry) Features() int { return e.Points + e.Paths + e.Areas }170171// Validate reports the first thing structurally wrong with the manifest. It172// checks the manifest alone; whether the entries it promises exist is173// [Reader.Validate], which has the archive in hand.174//175// An unknown format version is refused outright: a reader that guesses at a176// layout it does not know is worse than one that says so.177func (m Manifest) Validate() error {178 if m.Format != Format {179 return fmt.Errorf("format is %q, not %q", m.Format, Format)180 }181 if m.FormatVersion != FormatVersion {182 return fmt.Errorf("format version is %d, and this reads %d", m.FormatVersion, FormatVersion)183 }184 if err := ValidSlug(m.Volume.Slug); err != nil {185 return fmt.Errorf("volume slug: %w", err)186 }187 if m.Volume.Title == "" {188 return fmt.Errorf("volume %s has no title", m.Volume.Slug)189 }190 if m.Version.Stamp == "" || m.Version.CreatedAt == "" {191 return fmt.Errorf("volume %s carries no version", m.Volume.Slug)192 }193 if len(m.Worlds) == 0 {194 return fmt.Errorf("volume %s lists no worlds", m.Volume.Slug)195 }196 seen := make(map[string]bool, len(m.Worlds))197 for _, entry := range m.Worlds {198 if err := ValidSlug(entry.Slug); err != nil {199 return fmt.Errorf("world slug: %w", err)200 }201 if entry.Title == "" {202 return fmt.Errorf("world %s has no title", entry.Slug)203 }204 if seen[entry.Slug] {205 return fmt.Errorf("world slug %s is listed twice", entry.Slug)206 }207 seen[entry.Slug] = true208 }209 return nil210}211212// ValidSlug admits the names the format uses as identifiers: lowercase213// letters, digits, hyphen, and underscore, not leading with a separator.214//215// The rule exists because a slug is used unescaped as both a path segment and216// a URL segment. There is no reason for an identifier to need more, and217// everything a hostile name could smuggle -- separators, dots that climb --218// needs more.219func ValidSlug(slug string) error {220 if slug == "" {221 return fmt.Errorf("empty")222 }223 for _, r := range slug {224 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {225 continue226 }227 return fmt.Errorf("%q contains %q", slug, r)228 }229 if strings.HasPrefix(slug, "-") || strings.HasPrefix(slug, "_") {230 return fmt.Errorf("%q starts with a separator", slug)231 }232 return nil233}