clone

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

archive/golden-reference
atlas / internal / bundle / bundle.go
bundle.go 7.0K
1// Package bundle defines the .atlas file: one volume, complete, in one zip2// archive. A volume is the subject of an atlas -- a game, a planet, a city --3// and its bundle carries everything the viewer needs to draw it: world4// payloads, tile pyramids, category icons, behind a manifest naming what is5// inside, so the application can list a volume without reading past the6// manifest and can serve it without unpacking anything to disk.7//8// The format is Atlas's own, and so are its words. A volume holds worlds --9// the distinct grounds a reader can stand on, which for a game are its maps10// and for a captured city are its dated revisions -- and each world is seen11// through one or more lenses, the rasters that picture it. The format12// carries no trace of where a volume's data was captured from; a producer13// flattens whatever its source looks like into this shape, and the14// application only ever sees this shape.15package bundle1617import (18 "fmt"19 "strings"20)2122const (23 // Format names the container in the manifest, so a stray zip renamed to24 // .atlas is refused by name rather than by whatever fails first.25 Format = "atlas-bundle"26 // FormatVersion is bumped when the layout changes shape. A reader refuses27 // versions it does not know rather than guessing at them. Version 228 // renamed the format's concepts -- game to volume, map to world, variant29 // to lens -- through the manifest and payload keys alike. Version 330 // unified what a world holds: the payload's groups, zones, and pins31 // became one collections array of point, path, and area features, and32 // the manifest counts each kind instead of counting only pins.33 FormatVersion = 33435 // ManifestName is the entry the whole bundle is read through. The writer36 // places it first in the archive so a listing shows it first, though the37 // reader finds it by name wherever it sits.38 ManifestName = "atlas.json"39)4041// Manifest is what a bundle says about itself: which volume it is, which42// build of that volume's data it carries, and which worlds are inside.43//44// Conventions names the semantic-convention vocabulary the payloads were45// written against; zero is a bundle from before the conventions existed.46// It is a declaration, never a gate -- a reader that knows a newer or older47// vocabulary still opens the bundle and ignores what it cannot speak --48// but validation is stricter about a bundle that declares than one that49// does not.50type Manifest struct {51 Format string `json:"format"`52 FormatVersion int `json:"formatVersion"`53 Conventions int `json:"conventions,omitempty"`54 Volume Volume `json:"volume"`55 Version Version `json:"version"`56 TileGrid TileGrid `json:"tileGrid"`57 Worlds []WorldEntry `json:"worlds"`58}5960// Volume is the identity a bundle claims. The slug is the identity proper:61// two bundles naming the same slug are two versions of the same volume,62// however their files are named.63type Volume struct {64 Slug string `json:"slug"`65 Title string `json:"title"`66}6768// Version distinguishes builds of the same volume. The stamp is a content69// fingerprint -- two bundles with equal stamps hold the same data -- and70// CreatedAt orders them, so a newer bundle dropped beside an older one wins.71// Revision orders builds of the same capture: the data has not moved, but72// the policy that turns a capture into a bundle has, and the build under the73// newer policy is the one that should serve. Bundles from before the field74// existed read as revision zero and lose to any revised build.75type Version struct {76 Stamp string `json:"stamp"`77 CreatedAt string `json:"createdAt"`78 Revision int `json:"revision,omitempty"`79}8081// TileGrid is the window this volume's worlds are cut from: the zoom the82// source was captured at, where the first tile sits, and how big a tile and83// the world square are. A world cut from a window of its own overrides part84// of this in its own payload.85type TileGrid struct {86 SourceZoom int `json:"sourceZoom"`87 FirstTile int `json:"firstTile"`88 TileSize int `json:"tileSize"`89 Size int `json:"size"`90}9192// Coordinate is a point in world space, in the latitude and longitude of the93// volume's own projection.94type Coordinate struct {95 Lat float64 `json:"lat"`96 Lng float64 `json:"lng"`97}9899// WorldEntry lists one world: enough to offer it and to open it, and nothing100// that only matters once it is open. The slug names the world's payload101// entries -- worlds/<slug>.json, .bin, and .text -- and is the world's102// identity everywhere. Points, Paths, and Areas count the world's features103// by kind -- every feature is exactly one of the three -- so a listing can104// say how much a world holds without opening its payload, and validation105// can hold the payload to the promise.106type WorldEntry struct {107 Slug string `json:"slug"`108 Title string `json:"title"`109 Parent string `json:"parent,omitempty"`110 IconOutset string `json:"iconOutset,omitempty"`111 Center Coordinate `json:"center"`112 Points int `json:"points"`113 Paths int `json:"paths"`114 Areas int `json:"areas"`115 UpdatedAt string `json:"updatedAt"`116}117118// Validate reports the first thing structurally wrong with the manifest. It119// checks the manifest alone; whether the entries it promises exist is the120// reader's Validate, which has the archive in hand.121func (m Manifest) Validate() error {122 if m.Format != Format {123 return fmt.Errorf("format is %q, not %q", m.Format, Format)124 }125 if m.FormatVersion != FormatVersion {126 return fmt.Errorf("format version is %d, and this reads %d", m.FormatVersion, FormatVersion)127 }128 if err := validSlug(m.Volume.Slug); err != nil {129 return fmt.Errorf("volume slug: %w", err)130 }131 if m.Volume.Title == "" {132 return fmt.Errorf("volume %s has no title", m.Volume.Slug)133 }134 if m.Version.Stamp == "" || m.Version.CreatedAt == "" {135 return fmt.Errorf("volume %s carries no version", m.Volume.Slug)136 }137 if len(m.Worlds) == 0 {138 return fmt.Errorf("volume %s lists no worlds", m.Volume.Slug)139 }140 seen := make(map[string]bool, len(m.Worlds))141 for _, entry := range m.Worlds {142 if err := validSlug(entry.Slug); err != nil {143 return fmt.Errorf("world slug: %w", err)144 }145 if entry.Title == "" {146 return fmt.Errorf("world %s has no title", entry.Slug)147 }148 if seen[entry.Slug] {149 return fmt.Errorf("world slug %s is listed twice", entry.Slug)150 }151 seen[entry.Slug] = true152 }153 return nil154}155156// validSlug admits names that are safe as path segments and as URL segments157// without escaping: there is no reason for an identifier to need more, and158// everything a hostile name could smuggle -- separators, dots that climb --159// needs more.160func validSlug(slug string) error {161 if slug == "" {162 return fmt.Errorf("empty")163 }164 for _, r := range slug {165 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {166 continue167 }168 return fmt.Errorf("%q contains %q", slug, r)169 }170 if strings.HasPrefix(slug, "-") || strings.HasPrefix(slug, "_") {171 return fmt.Errorf("%q starts with a separator", slug)172 }173 return nil174}