clone

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

feat: give every game a bundle of its own

Dami committed 80517c8
A .atlas file is one game complete - manifest, map payloads, tile
pyramids, category icons - in a zip archive the application can serve
straight from stored entries. The format is Atlas's own: games and maps
are keyed by slug alone, the numeric identities and the source name the
capture pipeline carries stay out of it, and the manifest fingerprints
its contents so two builds of a game can be told apart and an unchanged
one skipped.

The new internal/bundle package owns the format end to end: manifest
validation, a writer producers build through, a reader that refuses
what is not a bundle, and a Validate pass that checks the manifest's
promises - payloads present, pin counts agreed, tile levels filled,
icons resolvable, no live URLs - the moment a bundle is written rather
than in the application's tests. The packed-location codec moves here
from tools/generate, and gains the Go-side reader the frontend's
decoder was previously checked against by copy.

tools/generate grows a -bundles flag beside -output and emits one
bundle per game, carrying each tile pyramid's stamp into the bundle's
own so a rebuild leaves untouched games untouched. The embedded tree
is written exactly as before; nothing in the application reads bundles
yet.
commit
80517c874ed14dbf8911f7af1940c0da7ec7ff08
parent
a296ebbfc81e80cdddf567efa29ab7aeea0eea84
author
Dami <dami.etoile@gmail.com>
11 files changed, +1465 -121
Ainternal/bundle/bundle.go145++++++++++++++++
Ainternal/bundle/bundle_test.go266++++++++++++++++++++++++++++++
Ainternal/bundle/bundletest/bundletest.go155+++++++++++++++++
Ainternal/bundle/locations.go142++++++++++++++++
Ainternal/bundle/read.go209++++++++++++++++++++++++
Ainternal/bundle/stamp.go59+++++++
Ainternal/bundle/write.go91++++++++++
Atools/generate/bundles.go252++++++++++++++++++++++++++++
Mtools/generate/main.go100+++++++++--
Mtools/generate/main_test.go28++-
Mtools/generate/split.go139+++++----------
A internal/bundle/bundle.go +145 -0
@@ -0,0 +1,145 @@
1+// Package bundle defines the .atlas file: one game, complete, in one zip2+// archive. A bundle carries everything the viewer needs to draw its game --3+// map payloads, tile pyramids, category icons -- behind a manifest naming what4+// is inside, so the application can list a game without reading past the5+// manifest and can serve it without unpacking anything to disk.6+//7+// The format is Atlas's own. It carries no trace of where a game's data was8+// captured from; a producer flattens whatever its source looks like into this9+// shape, and the application only ever sees this shape.10+package bundle11+12+import (13+ "fmt"14+ "strings"15+)16+17+const (18+ // Format names the container in the manifest, so a stray zip renamed to19+ // .atlas is refused by name rather than by whatever fails first.20+ Format = "atlas-bundle"21+ // FormatVersion is bumped when the layout changes shape. A reader refuses22+ // versions it does not know rather than guessing at them.23+ FormatVersion = 124+25+ // ManifestName is the entry the whole bundle is read through. The writer26+ // places it first in the archive so a listing shows it first, though the27+ // reader finds it by name wherever it sits.28+ ManifestName = "atlas.json"29+)30+31+// Manifest is what a bundle says about itself: which game it is, which build32+// of that game's data it carries, and which maps are inside.33+type Manifest struct {34+ Format string `json:"format"`35+ FormatVersion int `json:"formatVersion"`36+ Game Game `json:"game"`37+ Version Version `json:"version"`38+ TileGrid TileGrid `json:"tileGrid"`39+ Maps []MapEntry `json:"maps"`40+}41+42+// Game is the identity a bundle claims. The slug is the identity proper: two43+// bundles naming the same slug are two versions of the same game, however44+// their files are named.45+type Game struct {46+ Slug string `json:"slug"`47+ Title string `json:"title"`48+}49+50+// Version distinguishes builds of the same game. The stamp is a content51+// fingerprint -- two bundles with equal stamps hold the same data -- and52+// CreatedAt orders them, so a newer bundle dropped beside an older one wins.53+type Version struct {54+ Stamp string `json:"stamp"`55+ CreatedAt string `json:"createdAt"`56+}57+58+// TileGrid is the window this game's maps are cut from: the zoom the source59+// was captured at, where the first tile sits, and how big a tile and the world60+// are. A map cut from a window of its own overrides part of this in its own61+// payload.62+type TileGrid struct {63+ SourceZoom int `json:"sourceZoom"`64+ FirstTile int `json:"firstTile"`65+ TileSize int `json:"tileSize"`66+ Size int `json:"size"`67+}68+69+// Coordinate is a point in map space, in the latitude and longitude of the70+// game's own projection.71+type Coordinate struct {72+ Lat float64 `json:"lat"`73+ Lng float64 `json:"lng"`74+}75+76+// MapEntry lists one map: enough to offer it and to open it, and nothing that77+// only matters once it is open. The slug names the map's payload entries --78+// maps/<slug>.json, .bin, and .text -- and is the map's identity everywhere.79+type MapEntry struct {80+ Slug string `json:"slug"`81+ Title string `json:"title"`82+ Parent string `json:"parent,omitempty"`83+ IconOutset string `json:"iconOutset,omitempty"`84+ Center Coordinate `json:"center"`85+ PinCount int `json:"pinCount"`86+ UpdatedAt string `json:"updatedAt"`87+}88+89+// Validate reports the first thing structurally wrong with the manifest. It90+// checks the manifest alone; whether the entries it promises exist is the91+// reader's Validate, which has the archive in hand.92+func (m Manifest) Validate() error {93+ if m.Format != Format {94+ return fmt.Errorf("format is %q, not %q", m.Format, Format)95+ }96+ if m.FormatVersion != FormatVersion {97+ return fmt.Errorf("format version is %d, and this reads %d", m.FormatVersion, FormatVersion)98+ }99+ if err := validSlug(m.Game.Slug); err != nil {100+ return fmt.Errorf("game slug: %w", err)101+ }102+ if m.Game.Title == "" {103+ return fmt.Errorf("game %s has no title", m.Game.Slug)104+ }105+ if m.Version.Stamp == "" || m.Version.CreatedAt == "" {106+ return fmt.Errorf("game %s carries no version", m.Game.Slug)107+ }108+ if len(m.Maps) == 0 {109+ return fmt.Errorf("game %s lists no maps", m.Game.Slug)110+ }111+ seen := make(map[string]bool, len(m.Maps))112+ for _, entry := range m.Maps {113+ if err := validSlug(entry.Slug); err != nil {114+ return fmt.Errorf("map slug: %w", err)115+ }116+ if entry.Title == "" {117+ return fmt.Errorf("map %s has no title", entry.Slug)118+ }119+ if seen[entry.Slug] {120+ return fmt.Errorf("map slug %s is listed twice", entry.Slug)121+ }122+ seen[entry.Slug] = true123+ }124+ return nil125+}126+127+// validSlug admits names that are safe as path segments and as URL segments128+// without escaping: there is no reason for an identifier to need more, and129+// everything a hostile name could smuggle -- separators, dots that climb --130+// needs more.131+func validSlug(slug string) error {132+ if slug == "" {133+ return fmt.Errorf("empty")134+ }135+ for _, r := range slug {136+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {137+ continue138+ }139+ return fmt.Errorf("%q contains %q", slug, r)140+ }141+ if strings.HasPrefix(slug, "-") || strings.HasPrefix(slug, "_") {142+ return fmt.Errorf("%q starts with a separator", slug)143+ }144+ return nil145+}
A internal/bundle/bundle_test.go +266 -0
@@ -0,0 +1,266 @@
1+package bundle_test2+3+import (4+ "archive/zip"5+ "bytes"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/atlas/internal/bundle"12+ "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"13+)14+15+func validManifest() bundle.Manifest {16+ return bundle.Manifest{17+ Format: bundle.Format,18+ FormatVersion: bundle.FormatVersion,19+ Game: bundle.Game{Slug: "fixture", Title: "Fixture"},20+ Version: bundle.Version{Stamp: "abc", CreatedAt: "2026-01-01T00:00:00Z"},21+ Maps: []bundle.MapEntry{{Slug: "overworld", Title: "Overworld"}},22+ }23+}24+25+func TestManifestValidation(t *testing.T) {26+ if err := validManifest().Validate(); err != nil {27+ t.Fatalf("a valid manifest is refused: %v", err)28+ }29+ cases := map[string]func(*bundle.Manifest){30+ "wrong format": func(m *bundle.Manifest) { m.Format = "zip-of-things" },31+ "unknown version": func(m *bundle.Manifest) { m.FormatVersion = 99 },32+ "empty game slug": func(m *bundle.Manifest) { m.Game.Slug = "" },33+ "unsafe game slug": func(m *bundle.Manifest) { m.Game.Slug = "../escape" },34+ "uppercase game slug": func(m *bundle.Manifest) { m.Game.Slug = "Fallout76" },35+ "untitled game": func(m *bundle.Manifest) { m.Game.Title = "" },36+ "missing stamp": func(m *bundle.Manifest) { m.Version.Stamp = "" },37+ "missing created at": func(m *bundle.Manifest) { m.Version.CreatedAt = "" },38+ "no maps": func(m *bundle.Manifest) { m.Maps = nil },39+ "unsafe map slug": func(m *bundle.Manifest) { m.Maps[0].Slug = "a/b" },40+ "duplicate map slugs": func(m *bundle.Manifest) { m.Maps = append(m.Maps, m.Maps[0]) },41+ }42+ for name, corrupt := range cases {43+ manifest := validManifest()44+ corrupt(&manifest)45+ if err := manifest.Validate(); err == nil {46+ t.Errorf("a manifest with %s is accepted", name)47+ }48+ }49+}50+51+func TestWriterRefusesUnsafeAndRepeatedEntries(t *testing.T) {52+ writer, err := bundle.NewWriter(&bytes.Buffer{}, validManifest())53+ if err != nil {54+ t.Fatal(err)55+ }56+ for _, name := range []string{"", "/etc/hosts", "maps/../escape", "tiles/", bundle.ManifestName} {57+ if err := writer.AddDeflated(name, []byte("x")); err == nil {58+ t.Errorf("entry %q is accepted", name)59+ }60+ }61+ if err := writer.AddDeflated("maps/overworld.json", []byte("{}")); err != nil {62+ t.Fatal(err)63+ }64+ if err := writer.AddDeflated("maps/overworld.json", []byte("{}")); err == nil {65+ t.Error("the same entry is accepted twice")66+ }67+}68+69+func TestOpenRoundTripsWhatBuildWrites(t *testing.T) {70+ path := bundletest.Build(t, t.TempDir(), bundletest.Spec{71+ Slug: "fixture",72+ Maps: []bundletest.MapSpec{{73+ Slug: "overworld",74+ Pins: []bundletest.Pin{{Title: "Origin", Lat: 12.5, Lng: -3.25}, {Title: "Peak", Lat: 80, Lng: 170}},75+ }},76+ })77+ opened, err := bundle.Open(path)78+ if err != nil {79+ t.Fatal(err)80+ }81+ defer opened.Close()82+83+ if opened.Manifest.Game.Slug != "fixture" {84+ t.Errorf("game slug = %q", opened.Manifest.Game.Slug)85+ }86+ if got := opened.Manifest.Maps[0].PinCount; got != 2 {87+ t.Errorf("pin count = %d, want 2", got)88+ }89+ if err := opened.Validate(); err != nil {90+ t.Errorf("a fixture bundle fails validation: %v", err)91+ }92+93+ packed, err := opened.ReadEntry("maps/overworld.bin")94+ if err != nil {95+ t.Fatal(err)96+ }97+ locations, err := bundle.UnpackLocations(packed)98+ if err != nil {99+ t.Fatal(err)100+ }101+ if len(locations) != 2 || locations[0].Title != "Origin" || locations[1].Title != "Peak" {102+ t.Fatalf("locations round-tripped as %+v", locations)103+ }104+ if locations[0].Lat != 12.5 || locations[0].Lng != -3.25 {105+ t.Errorf("Origin landed at %v,%v", locations[0].Lat, locations[0].Lng)106+ }107+108+ reader, size, err := opened.OpenEntry("tiles/overworld/0/0/0.jpg")109+ if err != nil {110+ t.Fatal(err)111+ }112+ defer reader.Close()113+ if size <= 0 {114+ t.Errorf("tile size = %d", size)115+ }116+}117+118+func TestOpenRefusesWhatIsNotABundle(t *testing.T) {119+ dir := t.TempDir()120+121+ plain := filepath.Join(dir, "plain.atlas")122+ if err := os.WriteFile(plain, []byte("not a zip at all"), 0o644); err != nil {123+ t.Fatal(err)124+ }125+ if _, err := bundle.Open(plain); err == nil {126+ t.Error("a file that is not an archive is accepted")127+ }128+129+ // A real zip with no manifest is somebody's archive wearing our suffix.130+ bare := filepath.Join(dir, "bare.atlas")131+ if err := os.WriteFile(bare, zipWithout(t, bare), 0o644); err != nil {132+ t.Fatal(err)133+ }134+ if _, err := bundle.Open(bare); err == nil || !strings.Contains(err.Error(), bundle.ManifestName) {135+ t.Errorf("a zip without a manifest is refused with %v, wanting a complaint about %s", err, bundle.ManifestName)136+ }137+}138+139+func TestValidateCatchesBrokenPromises(t *testing.T) {140+ dir := t.TempDir()141+142+ // The manifest promises two maps; only one is written.143+ manifest := validManifest()144+ manifest.Maps = append(manifest.Maps, bundle.MapEntry{Slug: "underworld", Title: "Underworld"})145+ path := filepath.Join(dir, "fixture.atlas")146+ file, err := os.Create(path)147+ if err != nil {148+ t.Fatal(err)149+ }150+ writer, err := bundle.NewWriter(file, manifest)151+ if err != nil {152+ t.Fatal(err)153+ }154+ must := func(err error) {155+ t.Helper()156+ if err != nil {157+ t.Fatal(err)158+ }159+ }160+ must(writer.AddDeflated("maps/overworld.json",161+ []byte(`{"variants":[{"tiles":"overworld","minZoom":0,"maxZoom":0,"formats":["jpg"]}],"groups":[]}`)))162+ must(writer.AddStored("maps/overworld.bin", bytes.NewReader(bundle.PackLocations(nil))))163+ must(writer.AddDeflated("maps/overworld.text", []byte(`{}`)))164+ must(writer.AddStored("tiles/overworld/0/0/0.jpg", bytes.NewReader([]byte("raster"))))165+ must(writer.Close())166+ file.Close()167+168+ opened, err := bundle.Open(path)169+ if err != nil {170+ t.Fatal(err)171+ }172+ defer opened.Close()173+ if err := opened.Validate(); err == nil || !strings.Contains(err.Error(), "underworld") {174+ t.Errorf("a missing map validates with %v, wanting a complaint about underworld", err)175+ }176+}177+178+func TestValidateRefusesRuntimeURLsAndWrongCounts(t *testing.T) {179+ build := func(text string, pins int) *bundle.Bundle {180+ t.Helper()181+ manifest := validManifest()182+ manifest.Maps[0].PinCount = pins183+ path := filepath.Join(t.TempDir(), "fixture.atlas")184+ file, err := os.Create(path)185+ if err != nil {186+ t.Fatal(err)187+ }188+ writer, err := bundle.NewWriter(file, manifest)189+ if err != nil {190+ t.Fatal(err)191+ }192+ steps := []error{193+ writer.AddDeflated("maps/overworld.json",194+ []byte(`{"variants":[{"tiles":"overworld","minZoom":0,"maxZoom":0,"formats":["jpg"]}],"groups":[]}`)),195+ writer.AddStored("maps/overworld.bin",196+ bytes.NewReader(bundle.PackLocations([]bundle.Location{{ID: 1, Title: "Origin"}}))),197+ writer.AddDeflated("maps/overworld.text", []byte(text)),198+ writer.AddStored("tiles/overworld/0/0/0.jpg", bytes.NewReader([]byte("raster"))),199+ writer.Close(),200+ file.Close(),201+ }202+ for _, err := range steps {203+ if err != nil {204+ t.Fatal(err)205+ }206+ }207+ opened, err := bundle.Open(path)208+ if err != nil {209+ t.Fatal(err)210+ }211+ t.Cleanup(func() { opened.Close() })212+ return opened213+ }214+215+ if err := build(`{"1":{"d":"see https://mapgenie.io/x"}}`, 1).Validate(); err == nil ||216+ !strings.Contains(err.Error(), "runtime URL") {217+ t.Errorf("a payload carrying a live URL validates with %v", err)218+ }219+ if err := build(`{}`, 7).Validate(); err == nil || !strings.Contains(err.Error(), "7") {220+ t.Errorf("a wrong pin count validates with %v", err)221+ }222+ if err := build(`{}`, 1).Validate(); err != nil {223+ t.Errorf("a sound bundle is refused: %v", err)224+ }225+}226+227+func TestMoreRecentIsTotalAndPrefersNewer(t *testing.T) {228+ older := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-01-01T00:00:00Z"}))229+ newer := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-06-01T00:00:00Z"}))230+231+ if !bundle.MoreRecent(newer, older) || bundle.MoreRecent(older, newer) {232+ t.Error("creation time does not decide which bundle wins")233+ }234+ if bundle.MoreRecent(older, older) {235+ t.Error("a bundle shadows itself")236+ }237+}238+239+func open(t *testing.T, path string) *bundle.Bundle {240+ t.Helper()241+ opened, err := bundle.Open(path)242+ if err != nil {243+ t.Fatal(err)244+ }245+ t.Cleanup(func() { opened.Close() })246+ return opened247+}248+249+// zipWithout builds a genuine zip holding a single unrelated entry,250+// exercising the reader against an archive that is not a bundle.251+func zipWithout(t *testing.T, _ string) []byte {252+ t.Helper()253+ var buffer bytes.Buffer254+ zw := zip.NewWriter(&buffer)255+ entry, err := zw.Create("README.txt")256+ if err != nil {257+ t.Fatal(err)258+ }259+ if _, err := entry.Write([]byte("just an archive")); err != nil {260+ t.Fatal(err)261+ }262+ if err := zw.Close(); err != nil {263+ t.Fatal(err)264+ }265+ return buffer.Bytes()266+}
A internal/bundle/bundletest/bundletest.go +155 -0
@@ -0,0 +1,155 @@
1+// Package bundletest builds small real bundles for tests. A fixture is2+// written through the same Writer the bundler uses, so the writer and reader3+// verify each other and nothing opaque is checked into the tree.4+package bundletest5+6+import (7+ "bytes"8+ "encoding/json"9+ "os"10+ "path/filepath"11+ "strconv"12+ "testing"13+14+ "github.com/FelineStateMachine/atlas/internal/bundle"15+)16+17+// Spec describes one fixture game. Zero values are filled with something18+// plausible, so a test that only cares about, say, the slug states the slug19+// and nothing else.20+type Spec struct {21+ Slug string22+ Title string23+ CreatedAt string24+ Stamp string25+ Maps []MapSpec26+}27+28+// MapSpec describes one map of a fixture game: a single layer two zoom29+// levels deep, one category, and the given pins.30+type MapSpec struct {31+ Slug string32+ Title string33+ Pins []Pin34+}35+36+// Pin is one fixture location.37+type Pin struct {38+ Title string39+ Lat float6440+ Lng float6441+}42+43+// Build writes <spec.Slug>.atlas under dir and returns its path.44+func Build(t testing.TB, dir string, spec Spec) string {45+ t.Helper()46+ if spec.Title == "" {47+ spec.Title = spec.Slug48+ }49+ if spec.CreatedAt == "" {50+ spec.CreatedAt = "2026-01-01T00:00:00Z"51+ }52+ if spec.Stamp == "" {53+ spec.Stamp = bundle.HashBytes([]byte(spec.Slug + spec.CreatedAt))54+ }55+ if len(spec.Maps) == 0 {56+ spec.Maps = []MapSpec{{Slug: "overworld", Pins: []Pin{{Title: "Origin"}}}}57+ }58+59+ manifest := bundle.Manifest{60+ Format: bundle.Format,61+ FormatVersion: bundle.FormatVersion,62+ Game: bundle.Game{Slug: spec.Slug, Title: spec.Title},63+ Version: bundle.Version{Stamp: spec.Stamp, CreatedAt: spec.CreatedAt},64+ TileGrid: bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},65+ }66+ for _, m := range spec.Maps {67+ if m.Title == "" {68+ m.Title = m.Slug69+ }70+ manifest.Maps = append(manifest.Maps, bundle.MapEntry{71+ Slug: m.Slug,72+ Title: m.Title,73+ PinCount: len(m.Pins),74+ UpdatedAt: spec.CreatedAt,75+ })76+ }77+78+ path := filepath.Join(dir, spec.Slug+".atlas")79+ file, err := os.Create(path)80+ if err != nil {81+ t.Fatal(err)82+ }83+ defer file.Close()84+ writer, err := bundle.NewWriter(file, manifest)85+ if err != nil {86+ t.Fatal(err)87+ }88+89+ for _, m := range spec.Maps {90+ detail := map[string]any{91+ "variants": []map[string]any{{92+ "name": "Default",93+ "tiles": m.Slug,94+ "minZoom": 0,95+ "maxZoom": 1,96+ "fullZoom": 1,97+ "sourceZoom": 1,98+ "formats": []string{"jpg", "jpg"},99+ }},100+ "groups": []map[string]any{{101+ "id": 1,102+ "title": "Markers",103+ "categories": []map[string]any{{104+ "id": 1, "title": "Marker", "iconAsset": "marker.svg",105+ "displayType": "", "visible": true,106+ }},107+ }},108+ }109+ payload, err := json.Marshal(detail)110+ if err != nil {111+ t.Fatal(err)112+ }113+ add(t, writer.AddDeflated("maps/"+m.Slug+".json", payload))114+115+ locations := make([]bundle.Location, len(m.Pins))116+ text := make(map[string]any)117+ for index, pin := range m.Pins {118+ locations[index] = bundle.Location{119+ ID: int64(index + 1),120+ Title: pin.Title,121+ Lat: pin.Lat,122+ Lng: pin.Lng,123+ }124+ text[strconv.Itoa(index+1)] = map[string]string{"d": "About " + pin.Title}125+ }126+ packed := bundle.PackLocations(locations)127+ add(t, writer.AddStored("maps/"+m.Slug+".bin", bytes.NewReader(packed)))128+ notes, err := json.Marshal(text)129+ if err != nil {130+ t.Fatal(err)131+ }132+ add(t, writer.AddDeflated("maps/"+m.Slug+".text", notes))133+134+ for _, tile := range []string{"0/0/0.jpg", "1/0/0.jpg"} {135+ raster := []byte("jpeg bytes of " + m.Slug + "/" + tile)136+ add(t, writer.AddStored("tiles/"+m.Slug+"/"+tile, bytes.NewReader(raster)))137+ }138+ }139+ add(t, writer.AddDeflated("icons/marker.svg", []byte("<svg/>")))140+141+ if err := writer.Close(); err != nil {142+ t.Fatal(err)143+ }144+ if err := file.Close(); err != nil {145+ t.Fatal(err)146+ }147+ return path148+}149+150+func add(t testing.TB, err error) {151+ t.Helper()152+ if err != nil {153+ t.Fatal(err)154+ }155+}
A internal/bundle/locations.go +142 -0
@@ -0,0 +1,142 @@
1+package bundle2+3+import (4+ "encoding/binary"5+ "fmt"6+ "math"7+)8+9+const (10+ locationMagic = "ATLASLOC"11+ // 2 added the shard column. A map split into layers offers one at a time,12+ // and without it every layer's locations were drawn over every other.13+ locationVersion = 214+)15+16+// Location is one pin as the packed payload carries it. Owner is the position17+// of the pin's category in the map's flattened category order, which is the18+// only category identity the viewer needs once the payload JSON has listed19+// the categories in that same order.20+type Location struct {21+ ID int6422+ Title string23+ Lat float6424+ Lng float6425+ Region int6426+ Shard int6427+ Owner uint1628+}29+30+// PackLocations lays the locations out as parallel arrays, four-byte fields31+// first so a reader can view each one directly without copying or realigning.32+// Coordinates are single precision, which resolves far finer than a game map33+// is drawn.34+func PackLocations(locations []Location) []byte {35+ count := len(locations)36+37+ titles := make([]byte, 0, count*12)38+ offsets := make([]uint32, count+1)39+ for index, location := range locations {40+ offsets[index] = uint32(len(titles))41+ titles = append(titles, location.Title...)42+ }43+ offsets[count] = uint32(len(titles))44+45+ size := 16 + count*20 + (count+1)*4 + count*2 + len(titles)46+ out := make([]byte, size)47+ copy(out, locationMagic)48+ binary.LittleEndian.PutUint16(out[8:], locationVersion)49+ binary.LittleEndian.PutUint32(out[10:], uint32(count))50+ // out[14:16] is reserved, and keeps the arrays four-byte aligned.51+52+ at := 1653+ put32 := func(value uint32) {54+ binary.LittleEndian.PutUint32(out[at:], value)55+ at += 456+ }57+ for _, location := range locations {58+ put32(uint32(int32(location.ID)))59+ }60+ for _, location := range locations {61+ put32(math.Float32bits(float32(location.Lat)))62+ }63+ for _, location := range locations {64+ put32(math.Float32bits(float32(location.Lng)))65+ }66+ for _, location := range locations {67+ put32(uint32(int32(location.Region)))68+ }69+ for _, location := range locations {70+ put32(uint32(int32(location.Shard)))71+ }72+ for _, offset := range offsets {73+ put32(offset)74+ }75+ for _, location := range locations {76+ binary.LittleEndian.PutUint16(out[at:], location.Owner)77+ at += 278+ }79+ copy(out[at:], titles)80+ return out81+}82+83+// UnpackLocations is the reader of PackLocations, the same layout the viewer84+// decodes in the browser. It exists so a change to the packing that the85+// viewer could not read fails in Go -- in the bundler's validation and in86+// tests -- rather than on the map.87+func UnpackLocations(data []byte) ([]Location, error) {88+ if len(data) < 16 || string(data[:8]) != locationMagic {89+ return nil, fmt.Errorf("location payload is not in the expected form")90+ }91+ if version := binary.LittleEndian.Uint16(data[8:]); version != locationVersion {92+ return nil, fmt.Errorf("location payload is version %d, and this reads %d", version, locationVersion)93+ }94+ count := int(binary.LittleEndian.Uint32(data[10:]))95+ if size := 16 + count*20 + (count+1)*4 + count*2; count < 0 || len(data) < size {96+ return nil, fmt.Errorf("location payload is truncated")97+ }98+99+ at := 16100+ u32 := func(index int) uint32 { return binary.LittleEndian.Uint32(data[at+index*4:]) }101+ next := func(bytes int) { at += bytes }102+103+ out := make([]Location, count)104+ for index := range out {105+ out[index].ID = int64(int32(u32(index)))106+ }107+ next(count * 4)108+ for index := range out {109+ out[index].Lat = float64(math.Float32frombits(u32(index)))110+ }111+ next(count * 4)112+ for index := range out {113+ out[index].Lng = float64(math.Float32frombits(u32(index)))114+ }115+ next(count * 4)116+ for index := range out {117+ out[index].Region = int64(int32(u32(index)))118+ }119+ next(count * 4)120+ for index := range out {121+ out[index].Shard = int64(int32(u32(index)))122+ }123+ next(count * 4)124+ offsets := make([]uint32, count+1)125+ for index := range offsets {126+ offsets[index] = u32(index)127+ }128+ next((count + 1) * 4)129+ for index := range out {130+ out[index].Owner = binary.LittleEndian.Uint16(data[at+index*2:])131+ }132+ next(count * 2)133+134+ titles := data[at:]135+ if count > 0 && int(offsets[count]) > len(titles) {136+ return nil, fmt.Errorf("location titles are truncated")137+ }138+ for index := range out {139+ out[index].Title = string(titles[offsets[index]:offsets[index+1]])140+ }141+ return out, nil142+}
A internal/bundle/read.go +209 -0
@@ -0,0 +1,209 @@
1+package bundle2+3+import (4+ "archive/zip"5+ "encoding/json"6+ "fmt"7+ "io"8+ "strconv"9+ "strings"10+)11+12+// maxManifestSize bounds what Open will read as a manifest. A manifest lists13+// a game's maps and nothing per pin, so a megabyte is already generous; a14+// larger one is some other file wearing the name.15+const maxManifestSize = 1 << 2016+17+// Bundle is an opened .atlas file. The archive stays open for the bundle's18+// lifetime, so serving an entry is a seek and a read against the one held19+// descriptor rather than a fresh open of a multi-hundred-megabyte file.20+type Bundle struct {21+ Path string22+ Manifest Manifest23+24+ archive *zip.ReadCloser25+ entries map[string]*zip.File26+}27+28+// Open reads the manifest of the bundle at path and refuses anything that is29+// not a bundle this reader understands. It does not check the manifest's30+// promises against the archive's contents; that is Validate, which costs a31+// pass over every payload and is for producers and importers, not for every32+// launch.33+func Open(path string) (*Bundle, error) {34+ archive, err := zip.OpenReader(path)35+ if err != nil {36+ return nil, fmt.Errorf("open %s: %w", path, err)37+ }38+ bundle := &Bundle{39+ Path: path,40+ archive: archive,41+ entries: make(map[string]*zip.File, len(archive.File)),42+ }43+ for _, file := range archive.File {44+ bundle.entries[file.Name] = file45+ }46+47+ manifest, ok := bundle.entries[ManifestName]48+ if !ok {49+ archive.Close()50+ return nil, fmt.Errorf("%s carries no %s, so it is not a bundle", path, ManifestName)51+ }52+ if manifest.UncompressedSize64 > maxManifestSize {53+ archive.Close()54+ return nil, fmt.Errorf("%s: manifest is implausibly large", path)55+ }56+ reader, err := manifest.Open()57+ if err != nil {58+ archive.Close()59+ return nil, fmt.Errorf("%s: open manifest: %w", path, err)60+ }61+ data, err := io.ReadAll(io.LimitReader(reader, maxManifestSize))62+ reader.Close()63+ if err != nil {64+ archive.Close()65+ return nil, fmt.Errorf("%s: read manifest: %w", path, err)66+ }67+ if err := json.Unmarshal(data, &bundle.Manifest); err != nil {68+ archive.Close()69+ return nil, fmt.Errorf("%s: decode manifest: %w", path, err)70+ }71+ if err := bundle.Manifest.Validate(); err != nil {72+ archive.Close()73+ return nil, fmt.Errorf("%s: %w", path, err)74+ }75+ return bundle, nil76+}77+78+// Has reports whether the bundle holds an entry by this name.79+func (b *Bundle) Has(name string) bool {80+ _, ok := b.entries[name]81+ return ok82+}83+84+// OpenEntry opens one entry for reading and reports its uncompressed size, so85+// a server can announce a length before streaming.86+func (b *Bundle) OpenEntry(name string) (io.ReadCloser, int64, error) {87+ entry, ok := b.entries[name]88+ if !ok {89+ return nil, 0, fmt.Errorf("%s holds no %s", b.Path, name)90+ }91+ reader, err := entry.Open()92+ if err != nil {93+ return nil, 0, err94+ }95+ return reader, int64(entry.UncompressedSize64), nil96+}97+98+// ReadEntry reads one entry whole.99+func (b *Bundle) ReadEntry(name string) ([]byte, error) {100+ reader, _, err := b.OpenEntry(name)101+ if err != nil {102+ return nil, err103+ }104+ defer reader.Close()105+ return io.ReadAll(reader)106+}107+108+// Close releases the archive. Entries opened before must not be read after.109+func (b *Bundle) Close() error {110+ return b.archive.Close()111+}112+113+// payloadPeek is the sliver of a map payload that validation reads: which114+// pyramids its layers draw from and which icons its categories name.115+type payloadPeek struct {116+ Variants []struct {117+ Tiles string `json:"tiles"`118+ MinZoom int `json:"minZoom"`119+ MaxZoom int `json:"maxZoom"`120+ Formats []string `json:"formats"`121+ } `json:"variants"`122+ Groups []struct {123+ Categories []struct {124+ IconAsset string `json:"iconAsset"`125+ } `json:"categories"`126+ } `json:"groups"`127+}128+129+// Validate checks the manifest's promises against the archive: every listed130+// map has its three payloads, the packed locations agree with the advertised131+// pin count, every layer's tile levels hold tiles, every named icon exists,132+// and nothing carries a live URL -- Atlas runs with no network, so a URL in a133+// bundle is dead weight at best. Producers and importers run this; opening a134+// bundle at launch does not.135+func (b *Bundle) Validate() error {136+ levels := make(map[string]int)137+ for name := range b.entries {138+ if directory := name[:strings.LastIndexByte(name, '/')+1]; directory != "" {139+ levels[directory]++140+ }141+ }142+143+ for _, entry := range b.Manifest.Maps {144+ detail, err := b.ReadEntry("maps/" + entry.Slug + ".json")145+ if err != nil {146+ return fmt.Errorf("map %s: %w", entry.Slug, err)147+ }148+ text, err := b.ReadEntry("maps/" + entry.Slug + ".text")149+ if err != nil {150+ return fmt.Errorf("map %s: %w", entry.Slug, err)151+ }152+ for _, payload := range [][]byte{detail, text} {153+ for _, scheme := range []string{"http://", "https://"} {154+ if at := strings.Index(string(payload), scheme); at >= 0 {155+ return fmt.Errorf("map %s carries a runtime URL: %q",156+ entry.Slug, payload[at:min(at+120, len(payload))])157+ }158+ }159+ }160+161+ packed, err := b.ReadEntry("maps/" + entry.Slug + ".bin")162+ if err != nil {163+ return fmt.Errorf("map %s: %w", entry.Slug, err)164+ }165+ locations, err := UnpackLocations(packed)166+ if err != nil {167+ return fmt.Errorf("map %s: %w", entry.Slug, err)168+ }169+ if len(locations) != entry.PinCount {170+ return fmt.Errorf("map %s packs %d locations, and the manifest says %d",171+ entry.Slug, len(locations), entry.PinCount)172+ }173+174+ var peek payloadPeek175+ if err := json.Unmarshal(detail, &peek); err != nil {176+ return fmt.Errorf("map %s: decode payload: %w", entry.Slug, err)177+ }178+ if len(peek.Variants) == 0 {179+ return fmt.Errorf("map %s has no layers", entry.Slug)180+ }181+ for _, variant := range peek.Variants {182+ if len(variant.Formats) != variant.MaxZoom-variant.MinZoom+1 {183+ return fmt.Errorf("map %s layer %s names %d tile formats for %d levels",184+ entry.Slug, variant.Tiles, len(variant.Formats), variant.MaxZoom-variant.MinZoom+1)185+ }186+ for zoom := variant.MinZoom; zoom <= variant.MaxZoom; zoom++ {187+ prefix := "tiles/" + variant.Tiles + "/" + strconv.Itoa(zoom) + "/"188+ var held int189+ for directory, count := range levels {190+ if strings.HasPrefix(directory, prefix) {191+ held += count192+ }193+ }194+ if held == 0 {195+ return fmt.Errorf("map %s layer %s has an empty tile level %d",196+ entry.Slug, variant.Tiles, zoom)197+ }198+ }199+ }200+ for _, group := range peek.Groups {201+ for _, category := range group.Categories {202+ if category.IconAsset != "" && !b.Has("icons/"+category.IconAsset) {203+ return fmt.Errorf("map %s names a missing icon %s", entry.Slug, category.IconAsset)204+ }205+ }206+ }207+ }208+ return nil209+}
A internal/bundle/stamp.go +59 -0
@@ -0,0 +1,59 @@
1+package bundle2+3+import (4+ "crypto/sha256"5+ "encoding/hex"6+ "sort"7+ "strings"8+)9+10+// Stamp accumulates a content fingerprint for a bundle without requiring the11+// bundle's bytes: a producer feeds it a hash per part -- payloads it just12+// built, pyramid stamps its tile pipeline already computed -- and the sum is13+// stable however the parts arrived. Equal stamps mean a rebuild would write14+// the same bundle, which is what lets an unchanged game be skipped.15+type Stamp struct {16+ parts []string17+}18+19+// Add records one named part. Order does not matter; the sum sorts.20+func (s *Stamp) Add(name, hash string) {21+ s.parts = append(s.parts, name+" "+hash)22+}23+24+// Sum returns the fingerprint of everything added.25+func (s *Stamp) Sum() string {26+ sorted := make([]string, len(s.parts))27+ copy(sorted, s.parts)28+ sort.Strings(sorted)29+ digest := sha256.Sum256([]byte(strings.Join(sorted, "\n")))30+ return hex.EncodeToString(digest[:])31+}32+33+// HashBytes fingerprints one part for Add.34+func HashBytes(data []byte) string {35+ digest := sha256.Sum256(data)36+ return hex.EncodeToString(digest[:])37+}38+39+// MoreRecent reports whether a should shadow b when both claim the same game.40+// Creation time decides; the stamp and then the path break ties, so the choice41+// is total and two scans of the same directory always agree.42+func MoreRecent(a, b *Bundle) bool {43+ if a.Manifest.Version.CreatedAt != b.Manifest.Version.CreatedAt {44+ return a.Manifest.Version.CreatedAt > b.Manifest.Version.CreatedAt45+ }46+ if a.Manifest.Version.Stamp != b.Manifest.Version.Stamp {47+ return a.Manifest.Version.Stamp > b.Manifest.Version.Stamp48+ }49+ return a.Path > b.Path50+}51+52+// ShortStamp is the stamp as URLs and logs carry it: long enough that two53+// builds of a game will not collide, short enough to read past.54+func ShortStamp(stamp string) string {55+ if len(stamp) <= 12 {56+ return stamp57+ }58+ return stamp[:12]59+}
A internal/bundle/write.go +91 -0
@@ -0,0 +1,91 @@
1+package bundle2+3+import (4+ "archive/zip"5+ "encoding/json"6+ "fmt"7+ "io"8+ "strings"9+)10+11+// Writer builds a bundle around a manifest it validates up front, so a12+// producer cannot finish writing an archive the reader would refuse to open.13+type Writer struct {14+ zw *zip.Writer15+ names map[string]bool16+}17+18+// NewWriter starts a bundle on w. The manifest is written first, so a listing19+// of the archive leads with what it is.20+func NewWriter(w io.Writer, manifest Manifest) (*Writer, error) {21+ if err := manifest.Validate(); err != nil {22+ return nil, fmt.Errorf("manifest: %w", err)23+ }24+ data, err := json.Marshal(manifest)25+ if err != nil {26+ return nil, fmt.Errorf("marshal manifest: %w", err)27+ }28+ out := &Writer{zw: zip.NewWriter(w), names: map[string]bool{ManifestName: true}}29+ entry, err := out.zw.CreateHeader(&zip.FileHeader{Name: ManifestName, Method: zip.Deflate})30+ if err != nil {31+ return nil, err32+ }33+ if _, err := entry.Write(data); err != nil {34+ return nil, err35+ }36+ return out, nil37+}38+39+// AddStored copies r into the bundle uncompressed. Tiles and packed locations40+// go in this way: they are already dense, and a stored entry is served as a41+// straight byte range where a deflated one is inflated on every read.42+func (w *Writer) AddStored(name string, r io.Reader) error {43+ entry, err := w.create(name, zip.Store)44+ if err != nil {45+ return err46+ }47+ _, err = io.Copy(entry, r)48+ return err49+}50+51+// AddDeflated writes data into the bundle compressed, which suits the JSON52+// payloads: text shrinks severalfold and is read whole when a map opens.53+func (w *Writer) AddDeflated(name string, data []byte) error {54+ entry, err := w.create(name, zip.Deflate)55+ if err != nil {56+ return err57+ }58+ _, err = entry.Write(data)59+ return err60+}61+62+// Close finishes the archive. The bundle is not readable until it returns.63+func (w *Writer) Close() error {64+ return w.zw.Close()65+}66+67+func (w *Writer) create(name string, method uint16) (io.Writer, error) {68+ if err := validEntryName(name); err != nil {69+ return nil, err70+ }71+ if w.names[name] {72+ return nil, fmt.Errorf("entry %s is written twice", name)73+ }74+ w.names[name] = true75+ return w.zw.CreateHeader(&zip.FileHeader{Name: name, Method: method})76+}77+78+// validEntryName admits the names the format uses -- slash-separated relative79+// paths of plain segments -- and nothing a hostile or careless producer could80+// use to climb out of the archive's own namespace.81+func validEntryName(name string) error {82+ if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {83+ return fmt.Errorf("entry name %q is not a relative file path", name)84+ }85+ for segment := range strings.SplitSeq(name, "/") {86+ if segment == "" || segment == "." || segment == ".." {87+ return fmt.Errorf("entry name %q climbs or stutters", name)88+ }89+ }90+ return nil91+}
A tools/generate/bundles.go +252 -0
@@ -0,0 +1,252 @@
1+package main2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "io/fs"8+ "os"9+ "path/filepath"10+ "sort"11+ "strings"12+ "time"13+14+ "github.com/FelineStateMachine/atlas/internal/bundle"15+)16+17+// writeBundles packs each game into its own .atlas file. A bundle is the18+// game complete -- payloads, tiles, icons -- named by nothing but its own19+// slug, so the file can travel on its own and land in any Atlas.20+func writeBundles(out catalog, tiles tileManifest, tilesDir, bundleDir string) error {21+ if err := os.MkdirAll(bundleDir, 0o755); err != nil {22+ return fmt.Errorf("create bundle directory: %w", err)23+ }24+ stampByAsset := make(map[string]string, len(tiles.Variants))25+ for _, variant := range tiles.Variants {26+ stampByAsset[variant.AssetPath] = variant.Stamp27+ }28+29+ kept := make(map[string]bool, len(out.Games))30+ for _, game := range out.Games {31+ kept[game.Slug+".atlas"] = true32+ if err := writeGameBundle(game, out.TileGrid, stampByAsset, tilesDir, bundleDir); err != nil {33+ return fmt.Errorf("%s: %w", game.Title, err)34+ }35+ }36+37+ // A game that has left the catalog leaves the directory too, the same way38+ // tools/tiles prunes pyramids: what the source no longer names is not39+ // kept around to shadow anything.40+ entries, err := os.ReadDir(bundleDir)41+ if err != nil {42+ return err43+ }44+ for _, entry := range entries {45+ name := entry.Name()46+ if entry.IsDir() || kept[name] || !strings.HasSuffix(name, ".atlas") {47+ continue48+ }49+ if err := os.Remove(filepath.Join(bundleDir, name)); err != nil {50+ return fmt.Errorf("prune %s: %w", name, err)51+ }52+ }53+ return nil54+}55+56+// gamePayload is one map's three parts, built once and used for both the57+// stamp and the archive.58+type gamePayload struct {59+ slug string60+ detail []byte61+ packed []byte62+ text []byte63+}64+65+func writeGameBundle(66+ game catalogGame,67+ grid tileGrid,68+ stampByAsset map[string]string,69+ tilesDir, bundleDir string,70+) error {71+ manifest := bundle.Manifest{72+ Format: bundle.Format,73+ FormatVersion: bundle.FormatVersion,74+ Game: bundle.Game{Slug: game.Slug, Title: game.Title},75+ TileGrid: bundle.TileGrid{76+ SourceZoom: grid.SourceZoom,77+ FirstTile: grid.FirstTile,78+ TileSize: grid.TileSize,79+ Size: grid.Size,80+ },81+ }82+83+ // Pyramids are shared: several pieces of a split sheet draw from the one84+ // raster, so tile sets are collected across maps and written once.85+ pyramids := make(map[string]string)86+ var payloads []gamePayload87+ var stamp bundle.Stamp88+ for _, m := range game.Maps {89+ detail, packed, text := buildPayload(m)90+ detail.Variants = append([]variant(nil), detail.Variants...)91+ for index := range detail.Variants {92+ local := localPyramidName(game.Slug, detail.Variants[index].Tiles)93+ pyramids[local] = detail.Variants[index].Tiles94+ detail.Variants[index].Tiles = local95+ }96+ for groupIndex := range detail.Groups {97+ categories := detail.Groups[groupIndex].Categories98+ for index := range categories {99+ categories[index].IconAsset = strings.TrimPrefix(categories[index].IconAsset, game.Slug+"/")100+ }101+ }102+103+ detailJSON, err := json.Marshal(detail)104+ if err != nil {105+ return fmt.Errorf("map %s: marshal payload: %w", m.Slug, err)106+ }107+ textJSON, err := json.Marshal(text)108+ if err != nil {109+ return fmt.Errorf("map %s: marshal text: %w", m.Slug, err)110+ }111+ payloads = append(payloads, gamePayload{slug: m.Slug, detail: detailJSON, packed: packed, text: textJSON})112+ stamp.Add("maps/"+m.Slug+".json", bundle.HashBytes(detailJSON))113+ stamp.Add("maps/"+m.Slug+".bin", bundle.HashBytes(packed))114+ stamp.Add("maps/"+m.Slug+".text", bundle.HashBytes(textJSON))115+116+ manifest.Maps = append(manifest.Maps, bundle.MapEntry{117+ Slug: m.Slug,118+ Title: m.Title,119+ Parent: m.Parent,120+ IconOutset: m.IconOutset,121+ Center: bundle.Coordinate{Lat: m.Center.Latitude, Lng: m.Center.Longitude},122+ PinCount: m.PinCount,123+ UpdatedAt: m.UpdatedAt,124+ })125+ }126+127+ // A pyramid's own stamp already names the source tiles and the tool that128+ // derived them, so the tiles weigh into the bundle's stamp without being129+ // read until the bundle is actually written.130+ for local, asset := range pyramids {131+ stamp.Add("tiles/"+local, stampByAsset[asset])132+ }133+ iconNames := make([]string, 0, len(game.Icons))134+ for name, data := range game.Icons {135+ iconNames = append(iconNames, name)136+ stamp.Add("icons/"+name, bundle.HashBytes(data))137+ }138+ sort.Strings(iconNames)139+ identity, err := json.Marshal(manifest)140+ if err != nil {141+ return err142+ }143+ stamp.Add(bundle.ManifestName, bundle.HashBytes(identity))144+ manifest.Version.Stamp = stamp.Sum()145+146+ // An unchanged game keeps its file, and with it its creation time, so a147+ // rebuild of everything does not read as an update of everything.148+ target := filepath.Join(bundleDir, game.Slug+".atlas")149+ if existing, err := bundle.Open(target); err == nil {150+ carried := existing.Manifest.Version.Stamp151+ existing.Close()152+ if carried == manifest.Version.Stamp {153+ return nil154+ }155+ }156+ manifest.Version.CreatedAt = time.Now().UTC().Format(time.RFC3339)157+158+ file, err := os.CreateTemp(bundleDir, game.Slug+".atlas.tmp-*")159+ if err != nil {160+ return err161+ }162+ defer os.Remove(file.Name())163+ defer file.Close()164+165+ writer, err := bundle.NewWriter(file, manifest)166+ if err != nil {167+ return err168+ }169+ for _, payload := range payloads {170+ if err := writer.AddDeflated("maps/"+payload.slug+".json", payload.detail); err != nil {171+ return err172+ }173+ if err := writer.AddStored("maps/"+payload.slug+".bin", bytes.NewReader(payload.packed)); err != nil {174+ return err175+ }176+ if err := writer.AddDeflated("maps/"+payload.slug+".text", payload.text); err != nil {177+ return err178+ }179+ }180+ locals := make([]string, 0, len(pyramids))181+ for local := range pyramids {182+ locals = append(locals, local)183+ }184+ sort.Strings(locals)185+ for _, local := range locals {186+ if err := addPyramid(writer, filepath.Join(tilesDir, pyramids[local]), local); err != nil {187+ return err188+ }189+ }190+ for _, name := range iconNames {191+ if err := writer.AddDeflated("icons/"+name, game.Icons[name]); err != nil {192+ return err193+ }194+ }195+ if err := writer.Close(); err != nil {196+ return err197+ }198+ if err := file.Close(); err != nil {199+ return err200+ }201+ // CreateTemp opens private files; a bundle is an artifact anyone may copy.202+ if err := os.Chmod(file.Name(), 0o644); err != nil {203+ return err204+ }205+ if err := os.Rename(file.Name(), target); err != nil {206+ return err207+ }208+209+ // What the manifest promises is checked the moment the bundle exists, so210+ // a bad bundle fails this build rather than someone's import.211+ written, err := bundle.Open(target)212+ if err != nil {213+ return err214+ }215+ defer written.Close()216+ if err := written.Validate(); err != nil {217+ return fmt.Errorf("validate %s: %w", filepath.Base(target), err)218+ }219+ fmt.Printf("bundled %s (%d maps)\n", filepath.Base(target), len(game.Maps))220+ return nil221+}222+223+// localPyramidName strips the game the pyramid belonged to in the shared tile224+// tree. Inside a bundle the game is the file itself, so its pyramids are225+// named by map and layer alone.226+func localPyramidName(gameSlug, assetPath string) string {227+ return strings.TrimPrefix(assetPath, gameSlug+"__")228+}229+230+// addPyramid streams one tile pyramid off disk into the bundle, stored: the231+// rasters are already compressed, and a stored entry is served as a straight232+// byte range.233+func addPyramid(writer *bundle.Writer, pyramidDir, local string) error {234+ return filepath.WalkDir(pyramidDir, func(path string, entry fs.DirEntry, err error) error {235+ if err != nil {236+ return err237+ }238+ if entry.IsDir() {239+ return nil240+ }241+ relative, err := filepath.Rel(pyramidDir, path)242+ if err != nil {243+ return err244+ }245+ file, err := os.Open(path)246+ if err != nil {247+ return err248+ }249+ defer file.Close()250+ return writer.AddStored("tiles/"+local+"/"+filepath.ToSlash(relative), file)251+ })252+}
M tools/generate/main.go +82 -18
@@ -119,22 +119,38 @@ type tileGrid struct {
119119 Size int `json:"size"`120120 }121121 122+// mapGrid is where one map's world space sits in the source tile grid. Most123+// maps sit in the shared window and say nothing; a map cut from a window of its124+// own carries the two numbers that differ, and the catalog's grid answers for125+// the rest.126+type mapGrid struct {127+ SourceZoom int `json:"sourceZoom"`128+ FirstTile int `json:"firstTile"`129+}130+122131 type catalogGame struct {123132 ID int64 `json:"id"`124133 Title string `json:"title"`125134 Slug string `json:"slug"`126135 Maps []catalogMap `json:"maps"`136+ // Icons carries the game's category icons by bundle-relative name, read137+ // once from the archive so every writer -- the embedded tree today, the138+ // game's bundle -- draws on the same bytes.139+ Icons map[string][]byte `json:"-"`127140 }128141 129142 type catalogMap struct {130- ID int64 `json:"id"`131- Title string `json:"title"`132- Slug string `json:"slug"`133- IconOutset string `json:"iconOutset,omitempty"`134- Center coordinate `json:"center"`135- Variants []variant `json:"variants"`136- Groups []catalogGroup `json:"groups"`137- Zones []zone `json:"zones,omitempty"`143+ ID int64 `json:"id"`144+ Title string `json:"title"`145+ Slug string `json:"slug"`146+ IconOutset string `json:"iconOutset,omitempty"`147+ Center coordinate `json:"center"`148+ // Grid is carried only by a map whose window is not the shared one, so the149+ // catalog reads the same as it did for every map that is.150+ Grid *mapGrid `json:"grid,omitempty"`151+ Variants []variant `json:"variants"`152+ Groups []catalogGroup `json:"groups"`153+ Zones []zone `json:"zones,omitempty"`138154 // Parent names the map this one was split out of, so an inset sorts with139155 // the sheet it came from rather than alphabetically among unrelated maps.140156 Parent string `json:"parent,omitempty"`
@@ -191,10 +207,12 @@ type tileManifest struct {
191207 type tileVariantManifest struct {192208 SourcePath string `json:"sourcePath"`193209 AssetPath string `json:"assetPath"`210+ Stamp string `json:"stamp"`194211 MinZoom int `json:"minZoom"`195212 MaxZoom int `json:"maxZoom"`196213 FullZoom int `json:"fullZoom"`197214 SourceZoom int `json:"sourceZoom"`215+ Grid mapGrid `json:"grid"`198216 Formats []string `json:"formats"`199217 Bounds *contentBounds `json:"bounds"`200218 Interpolate bool `json:"interpolate"`
@@ -291,19 +309,24 @@ func main() {
291309 source := flag.String("source", "", "path containing fmg-archive")292310 tiles := flag.String("tiles", "", "generated tile manifest")293311 output := flag.String("output", "", "catalog JSON destination")312+ bundles := flag.String("bundles", "", "directory receiving one .atlas bundle per game")294313 flag.Parse()295314 296- if *source == "" || *tiles == "" || *output == "" {297- fmt.Fprintln(os.Stderr, "generate: -source, -tiles, and -output are required")315+ if *source == "" || *tiles == "" {316+ fmt.Fprintln(os.Stderr, "generate: -source and -tiles are required")298317 os.Exit(2)299318 }300- if err := run(*source, *tiles, *output); err != nil {319+ if *output == "" && *bundles == "" {320+ fmt.Fprintln(os.Stderr, "generate: nothing to write; pass -output, -bundles, or both")321+ os.Exit(2)322+ }323+ if err := run(*source, *tiles, *output, *bundles); err != nil {301324 fmt.Fprintln(os.Stderr, "generate:", err)302325 os.Exit(1)303326 }304327 }305328 306-func run(source, tileManifestPath, output string) error {329+func run(source, tileManifestPath, output, bundleDir string) error {307330 archiveRoot := filepath.Join(source, "fmg-archive")308331 var index archive309332 if err := readJSON(filepath.Join(archiveRoot, "archive.json"), &index); err != nil {
@@ -327,7 +350,10 @@ func run(source, tileManifestPath, output string) error {
327350 Size: tiles.Size,328351 },329352 }330- iconRoot := filepath.Join(filepath.Dir(output), "icons")353+ iconRoot := ""354+ if output != "" {355+ iconRoot = filepath.Join(filepath.Dir(output), "icons")356+ }331357 for _, gameRef := range index.Games {332358 game, err := buildGame(archiveRoot, iconRoot, tilesByPath, gameRef, out.TileGrid)333359 if err != nil {
@@ -339,7 +365,17 @@ func run(source, tileManifestPath, output string) error {
339365 }340366 341367 sort.Slice(out.Games, func(i, j int) bool { return out.Games[i].Title < out.Games[j].Title })342- return writeCatalog(out, output)368+ if output != "" {369+ if err := writeCatalog(out, output); err != nil {370+ return err371+ }372+ }373+ if bundleDir != "" {374+ if err := writeBundles(out, tiles, filepath.Dir(tileManifestPath), bundleDir); err != nil {375+ return err376+ }377+ }378+ return nil343379 }344380 345381 func buildGame(
@@ -455,11 +491,33 @@ func buildMap(
455491 if len(raw.Config.TileSets) == 0 {456492 return nil, "", fmt.Errorf("%w: no tile sets", errMapNotReady)457493 }494+ // The catalog's grid is the window most maps are cut from; this map's own495+ // takes its place below, and what is left of the shared one -- the size of496+ // the world and of a tile, which no map differs on -- carries through.497+ shared := grid458498 for _, set := range raw.Config.TileSets {459499 tiles, ok := tilesByPath[set.Path]460500 if !ok {461501 return nil, "", fmt.Errorf("%w: tile layer %s is missing", errMapNotReady, set.Path)462502 }503+ // Every layer of a map is a picture of the same ground, so they agree on504+ // the window it is cut from. One that does not would be a map drawn in505+ // two places at once, and no pin could be placed on it.506+ window := tiles.Grid507+ if window == (mapGrid{}) {508+ // An index written before layers carried their window: the shared509+ // one is what every map in it was cut from.510+ window = mapGrid{SourceZoom: shared.SourceZoom, FirstTile: shared.FirstTile}511+ }512+ if len(m.Variants) == 0 {513+ grid.SourceZoom, grid.FirstTile = window.SourceZoom, window.FirstTile514+ if window != (mapGrid{SourceZoom: shared.SourceZoom, FirstTile: shared.FirstTile}) {515+ m.Grid = &window516+ }517+ } else if window.SourceZoom != grid.SourceZoom || window.FirstTile != grid.FirstTile {518+ return nil, "", fmt.Errorf("%w: tile layer %s sits in a different window from %s",519+ errMapNotReady, set.Path, raw.Config.TileSets[0].Path)520+ }463521 m.Variants = append(m.Variants, variant{464522 Name: set.Name,465523 Tiles: tiles.AssetPath,
@@ -608,6 +666,7 @@ func attachGameIcons(gamePath, iconRoot string, game *catalogGame) error {
608666 if game.Slug == "" {609667 return nil610668 }669+ game.Icons = make(map[string][]byte)611670 copied := make(map[string]string)612671 for mapIndex := range game.Maps {613672 for groupIndex := range game.Maps[mapIndex].Groups {
@@ -620,7 +679,7 @@ func attachGameIcons(gamePath, iconRoot string, game *catalogGame) error {
620679 asset, found := copied[category.Icon]621680 if !found {622681 var err error623- asset, err = copyGameIcon(gamePath, iconRoot, game.Slug, category.Icon)682+ asset, err = copyGameIcon(gamePath, iconRoot, game, category.Icon)624683 if err != nil {625684 return err626685 }
@@ -636,8 +695,9 @@ func attachGameIcons(gamePath, iconRoot string, game *catalogGame) error {
636695 637696 // copyGameIcon takes whichever form the archive holds. Most games publish an638697 // icon font that renders to SVG; some publish a marker strip instead, which639-// slices into PNG.640-func copyGameIcon(gamePath, iconRoot, gameSlug, icon string) (string, error) {698+// slices into PNG. The bytes are kept on the game for the bundle writer, and699+// mirrored into iconRoot when the embedded tree is also being written.700+func copyGameIcon(gamePath, iconRoot string, game *catalogGame, icon string) (string, error) {641701 var data []byte642702 var extension string643703 for _, candidate := range []string{".svg", ".png"} {
@@ -655,7 +715,11 @@ func copyGameIcon(gamePath, iconRoot, gameSlug, icon string) (string, error) {
655715 if extension == "" {656716 return "", nil657717 }658- asset := filepath.ToSlash(filepath.Join(gameSlug, icon+extension))718+ game.Icons[icon+extension] = data719+ asset := filepath.ToSlash(filepath.Join(game.Slug, icon+extension))720+ if iconRoot == "" {721+ return asset, nil722+ }659723 destination := filepath.Join(iconRoot, filepath.FromSlash(asset))660724 if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {661725 return "", fmt.Errorf("create icon directory: %w", err)
M tools/generate/main_test.go +16 -12
@@ -132,24 +132,28 @@ func TestSortGameMapsPrefersPrimaryMap(t *testing.T) {
132132 // The packed payload is the only thing the frontend reads a location from, so133133 // a field left out of it is a field the map does not have. Shard went missing134134 // this way once, and every layer's pins drew over every other.135-func TestPackLocationsCarriesEveryColumn(t *testing.T) {135+func TestBuildPayloadPacksEveryColumn(t *testing.T) {136136 region := int64(77)137- locations := []catalogLocation{138- {ID: 11, Title: "Sky Mine", Latitude: 1.5, Longitude: -2.5, Shard: 1783},139- {ID: 12, Title: "Deep Well", Latitude: -3, Longitude: 4, RegionID: &region, Shard: 1785},140- }137+ m := catalogMap{Groups: []catalogGroup{{Categories: []catalogCategory{138+ {Locations: []catalogLocation{139+ {ID: 11, Title: "Sky Mine", Latitude: 1.5, Longitude: -2.5, Shard: 1783},140+ }},141+ {Locations: []catalogLocation{142+ {ID: 12, Title: "Deep Well", Latitude: -3, Longitude: 4, RegionID: &region, Shard: 1785},143+ }},144+ }}}}141145 142- packed := packLocations(locations, []uint16{0, 1})146+ _, packed, _ := buildPayload(m)143147 144- if magic := string(packed[:8]); magic != locationMagic {145- t.Fatalf("magic = %q, want %q", magic, locationMagic)148+ if magic := string(packed[:8]); magic != "ATLASLOC" {149+ t.Fatalf("magic = %q, want %q", magic, "ATLASLOC")146150 }147- if version := binary.LittleEndian.Uint16(packed[8:]); version != locationVersion {148- t.Fatalf("version = %d, want %d", version, locationVersion)151+ if version := binary.LittleEndian.Uint16(packed[8:]); version != 2 {152+ t.Fatalf("version = %d, want 2", version)149153 }150154 count := int(binary.LittleEndian.Uint32(packed[10:]))151- if count != len(locations) {152- t.Fatalf("count = %d, want %d", count, len(locations))155+ if count != 2 {156+ t.Fatalf("count = %d, want 2", count)153157 }154158 155159 column := func(index int) []int32 {
M tools/generate/split.go +48 -91
@@ -1,13 +1,13 @@
11 package main22 33 import (4- "encoding/binary"54 "encoding/json"65 "fmt"7- "math"86 "os"97 "path/filepath"108 "strconv"9+10+ "github.com/FelineStateMachine/atlas/internal/bundle"1111 )1212 1313 // The catalog was one file holding every location of every game, fetched whole
@@ -25,13 +25,6 @@ import (
2525 // so they are the part most worth not loading. Locations are overwhelmingly2626 // numbers, and numbers written as text cost several times what they measure.2727 28-const (29- locationMagic = "ATLASLOC"30- // 2 added the shard column. A map split into layers offers one at a time,31- // and without it every layer's locations were drawn over every other.32- locationVersion = 233-)34-3528 // mapIndex is a map as the index knows it: enough to list it and to open it,3629 // and nothing that only matters once it is open.3730 type mapIndex struct {
@@ -61,7 +54,13 @@ type catalogIndex struct {
6154 // mapDetail is everything needed to draw a map except its locations, which6255 // travel packed alongside.6356 type mapDetail struct {64- ID int64 `json:"id"`57+ // ID is the numeric identity the embedded tree keys its payload files by.58+ // Bundles key everything by slug and leave it unset.59+ ID int64 `json:"id,omitempty"`60+ // Grid travels with the layers it describes: a map cut from a window of its61+ // own is the only one that carries it, and it is needed exactly when the62+ // map is opened.63+ Grid *mapGrid `json:"grid,omitempty"`6564 Variants []variant `json:"variants"`6665 Groups []catalogGroup `json:"groups"`6766 Zones []zone `json:"zones,omitempty"`
@@ -119,12 +118,28 @@ func writeCatalog(out catalog, output string) error {
119118 120119 func writeMapPayload(payloadDir string, m catalogMap) error {121120 name := strconv.FormatInt(m.ID, 10)121+ detail, packed, text := buildPayload(m)122+ detail.ID = m.ID122123 124+ if err := writeJSONFile(filepath.Join(payloadDir, name+".json"), detail); err != nil {125+ return err126+ }127+ if err := os.WriteFile(filepath.Join(payloadDir, name+".bin"), packed, 0o644); err != nil {128+ return fmt.Errorf("write locations: %w", err)129+ }130+ return writeJSONFile(filepath.Join(payloadDir, name+".text"), text)131+}132+133+// buildPayload splits one map three ways: its layers, categories and regions134+// as a detail structure, its locations packed, and its descriptions keyed by135+// location. Both destinations -- the embedded tree and the game's bundle --136+// are written from this one split.137+func buildPayload(m catalogMap) (mapDetail, []byte, map[string]locationText) {123138 // Categories keep their identity; their locations travel packed, each124139 // carrying the position of its category in this same flattened order.125- detail := mapDetail{ID: m.ID, Variants: m.Variants, Zones: m.Zones}126- var locations []catalogLocation127- var owners []uint16140+ detail := mapDetail{Grid: m.Grid, Variants: m.Variants, Zones: m.Zones}141+ var locations []bundle.Location142+ text := make(map[string]locationText)128143 var ordinal uint16129144 for _, group := range m.Groups {130145 listed := catalogGroup{ID: group.ID, Title: group.Title}
@@ -133,89 +148,31 @@ func writeMapPayload(payloadDir string, m catalogMap) error {
133148 stripped.Locations = nil134149 listed.Categories = append(listed.Categories, stripped)135150 for _, location := range category.Locations {136- locations = append(locations, location)137- owners = append(owners, ordinal)151+ var region int64152+ if location.RegionID != nil {153+ region = *location.RegionID154+ }155+ locations = append(locations, bundle.Location{156+ ID: location.ID,157+ Title: location.Title,158+ Lat: location.Latitude,159+ Lng: location.Longitude,160+ Region: region,161+ Shard: location.Shard,162+ Owner: ordinal,163+ })164+ if location.Description != "" || len(location.Links) > 0 {165+ text[strconv.FormatInt(location.ID, 10)] = locationText{166+ Description: location.Description,167+ Links: location.Links,168+ }169+ }138170 }139171 ordinal++140172 }141173 detail.Groups = append(detail.Groups, listed)142174 }143-144- if err := writeJSONFile(filepath.Join(payloadDir, name+".json"), detail); err != nil {145- return err146- }147- if err := os.WriteFile(filepath.Join(payloadDir, name+".bin"), packLocations(locations, owners), 0o644); err != nil {148- return fmt.Errorf("write locations: %w", err)149- }150-151- text := make(map[string]locationText, len(locations))152- for _, location := range locations {153- if location.Description == "" && len(location.Links) == 0 {154- continue155- }156- text[strconv.FormatInt(location.ID, 10)] = locationText{157- Description: location.Description,158- Links: location.Links,159- }160- }161- return writeJSONFile(filepath.Join(payloadDir, name+".text"), text)162-}163-164-// packLocations lays the locations out as parallel arrays, four-byte fields165-// first so a reader can view each one directly without copying or realigning.166-// Coordinates are single precision, which resolves far finer than a game map167-// is drawn.168-func packLocations(locations []catalogLocation, owners []uint16) []byte {169- count := len(locations)170-171- titles := make([]byte, 0, count*12)172- offsets := make([]uint32, count+1)173- for index, location := range locations {174- offsets[index] = uint32(len(titles))175- titles = append(titles, location.Title...)176- }177- offsets[count] = uint32(len(titles))178-179- size := 16 + count*20 + (count+1)*4 + count*2 + len(titles)180- out := make([]byte, size)181- copy(out, locationMagic)182- binary.LittleEndian.PutUint16(out[8:], locationVersion)183- binary.LittleEndian.PutUint32(out[10:], uint32(count))184- // out[14:16] is reserved, and keeps the arrays four-byte aligned.185-186- at := 16187- put32 := func(value uint32) {188- binary.LittleEndian.PutUint32(out[at:], value)189- at += 4190- }191- for _, location := range locations {192- put32(uint32(int32(location.ID)))193- }194- for _, location := range locations {195- put32(math.Float32bits(float32(location.Latitude)))196- }197- for _, location := range locations {198- put32(math.Float32bits(float32(location.Longitude)))199- }200- for _, location := range locations {201- region := int32(0)202- if location.RegionID != nil {203- region = int32(*location.RegionID)204- }205- put32(uint32(region))206- }207- for _, location := range locations {208- put32(uint32(int32(location.Shard)))209- }210- for _, offset := range offsets {211- put32(offset)212- }213- for _, owner := range owners {214- binary.LittleEndian.PutUint16(out[at:], owner)215- at += 2216- }217- copy(out[at:], titles)218- return out175+ return detail, bundle.PackLocations(locations), text219176 }220177 221178 func writeJSONFile(path string, value any) error {