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 / tools / generate / main.go
main.go 31.7K
1// Command generate turns an FMG archive and the generated raster manifest2// into one .atlas bundle per game. The source archive stays outside the Go3// module; everything the application serves for a game travels in its bundle.4package main56import (7 "encoding/json"8 "errors"9 "flag"10 "fmt"11 "maps"12 "os"13 "path/filepath"14 "regexp"15 "sort"16 "strconv"17 "strings"1819 "github.com/FelineStateMachine/atlas/internal/arcgismap"20 "github.com/FelineStateMachine/atlas/internal/bundle"21 "github.com/FelineStateMachine/atlas/internal/icons"22 "github.com/FelineStateMachine/atlas/internal/ignmap"23 "github.com/FelineStateMachine/atlas/internal/pbmap"24 "github.com/FelineStateMachine/atlas/internal/semconv"25 "github.com/FelineStateMachine/atlas/internal/trekmap"26)2728type archive struct {29 Games []archiveGame `json:"games"`30}3132type archiveGame struct {33 Directory string `json:"directory"`34 ID int64 `json:"id"`35 Title string `json:"title"`36 // Source names the crawler that filled this directory. Older archives37 // predate the field; an empty value is MapGenie, the original source.38 Source string `json:"source"`39}4041type snapshotIndex struct {42 CapturedAt string `json:"capturedAt"`43 ContentHash string `json:"contentHash"`44 Kind string `json:"kind"`45}4647type rawMap struct {48 ID int64 `json:"id"`49 Title string `json:"title"`50 Slug string `json:"slug"`51 InitialLatitude float64 `json:"initial_latitude"`52 InitialLongitude float64 `json:"initial_longitude"`53 InitialZoom float64 `json:"initial_zoom"`54 Config rawConfig `json:"config"`55 Game rawGame `json:"game"`56 Groups []rawGroup `json:"groups"`57 Regions []rawRegion `json:"regions"`58 Collections []rawCollectionDecl `json:"atlas_collections"`59 Attrs map[string]string `json:"atlas_attrs"`60}6162// rawCollectionDecl is a translator's declaration of a curated collection its63// regions may claim (mgdoc.CollectionDecl on the producing side). MapGenie64// captures never spell the field, so their regions keep folding into the65// implicit collection.66type rawCollectionDecl struct {67 Key string `json:"key"`68 Title string `json:"title"`69 Attrs map[string]string `json:"atlas_attrs"`70}7172type rawGame struct {73 ID int64 `json:"id"`74 Title string `json:"title"`75 Slug string `json:"slug"`76}7778type rawConfig struct {79 TileSets []rawTileSet `json:"tile_sets"`80}8182type rawTileSet struct {83 Name string `json:"name"`84 Path string `json:"path"`85}8687type rawGroup struct {88 ID int64 `json:"id"`89 Title string `json:"title"`90 Color string `json:"color"`91 IconColor string `json:"icon_color"`92 Categories []rawCategory `json:"categories"`93}9495type rawCategory struct {96 ID int64 `json:"id"`97 Title string `json:"title"`98 Icon string `json:"icon"`99 Color string `json:"color"`100 IconColor string `json:"icon_color"`101 DisplayType string `json:"display_type"`102 Visible bool `json:"visible"`103 Locations []rawLocation `json:"locations"`104 Attrs map[string]string `json:"atlas_attrs"`105}106107type rawLocation struct {108 ID int64 `json:"id"`109 Title string `json:"title"`110 Description string `json:"description"`111 Latitude json.RawMessage `json:"latitude"`112 Longitude json.RawMessage `json:"longitude"`113 RegionID *int64 `json:"region_id"`114 Attrs map[string]string `json:"atlas_attrs"`115}116117type rawRegion struct {118 ID int64 `json:"id"`119 Title string `json:"title"`120 Subtitle string `json:"subtitle"`121 Description string `json:"description"`122 ParentRegionID *int64 `json:"parent_region_id"`123 CenterX json.RawMessage `json:"center_x"`124 CenterY json.RawMessage `json:"center_y"`125 Features []rawFeature `json:"features"`126 Collection string `json:"atlas_collection"`127 Attrs map[string]string `json:"atlas_attrs"`128}129130type rawFeature struct {131 Geometry geometry `json:"geometry"`132}133134type geometry struct {135 Type string `json:"type"`136 Coordinates json.RawMessage `json:"coordinates"`137}138139// catalog is the generator's working set: everything built from the archive,140// held only long enough to be written out one bundle per game.141type catalog struct {142 TileGrid tileGrid143 Volumes []catalogVolume144}145146type tileGrid struct {147 SourceZoom int `json:"sourceZoom"`148 FirstTile int `json:"firstTile"`149 TileSize int `json:"tileSize"`150 Size int `json:"size"`151}152153// worldGrid is where one map's world space sits in the source tile grid. Most154// maps sit in the shared window and say nothing; a map cut from a window of its155// own carries the two numbers that differ, and the catalog's grid answers for156// the rest.157type worldGrid struct {158 SourceZoom int `json:"sourceZoom"`159 FirstTile int `json:"firstTile"`160}161162type catalogVolume struct {163 ID int64 `json:"id"`164 Title string `json:"title"`165 Slug string `json:"slug"`166 Worlds []catalogWorld `json:"worlds"`167 // Icons carries the game's category icons by bundle-relative name, read168 // once from the archive so every writer -- the embedded tree today, the169 // game's bundle -- draws on the same bytes.170 Icons map[string][]byte `json:"-"`171}172173type catalogWorld struct {174 ID int64 `json:"id"`175 Title string `json:"title"`176 Slug string `json:"slug"`177 IconOutset string `json:"iconOutset,omitempty"`178 Center coordinate `json:"center"`179 // Attrs is the map's account of itself in the shared conventions --180 // geometry, marker outset -- validated against the registry before a181 // bundle is written.182 Attrs map[string]string `json:"attrs,omitempty"`183 // Grid is carried only by a map whose window is not the shared one, so the184 // catalog reads the same as it did for every map that is.185 Grid *worldGrid `json:"grid,omitempty"`186 Lenses []lens `json:"lenses"`187 // Collections is everything the map holds, in one model: each category of188 // pins as a point collection, and the regions folded into the implicit189 // collection (normalize.go). The v2 wire's groups and zones are190 // reconstructed from it only when a payload is written.191 Collections []worldCollection `json:"-"`192 // Parent names the map this one was split out of, so an inset sorts with193 // the sheet it came from rather than alphabetically among unrelated maps.194 Parent string `json:"parent,omitempty"`195 UpdatedAt string `json:"updatedAt"`196 // Merged is the account of every other source folded into this map:197 // what matched, what was added, what was held back and why. It rides in198 // the payload as provenance.199 Merged []mergedSource `json:"merged,omitempty"`200}201202type coordinate struct {203 Latitude float64 `json:"lat"`204 Longitude float64 `json:"lng"`205}206207type lens struct {208 Name string `json:"name"`209 Tiles string `json:"tiles"`210 MinZoom int `json:"minZoom"`211 MaxZoom int `json:"maxZoom"`212 FullZoom int `json:"fullZoom"`213 SourceZoom int `json:"sourceZoom"`214 Formats []string `json:"formats"`215 Bounds *contentBounds `json:"bounds,omitempty"`216 // Surface is the ground the map covers, where Bounds is the window cut from217 // the tile pyramid to draw it. On a piece of a sheet the window is grown to218 // take in the title drawn beside it, so anything dividing the map up -- the219 // geohash grid -- measures the surface instead and leaves no cell on margin.220 Surface *contentBounds `json:"surface,omitempty"`221 Interpolate bool `json:"interpolate"`222 Background string `json:"background,omitempty"`223 Shard int64 `json:"shard,omitempty"`224 Coverage map[string]*levelCoverage `json:"coverage,omitempty"`225}226227type levelCoverage struct {228 X int `json:"x"`229 Y int `json:"y"`230 W int `json:"w"`231 H int `json:"h"`232 Bits string `json:"bits"`233}234235type contentBounds struct {236 X int `json:"x"`237 Y int `json:"y"`238 Width int `json:"width"`239 Height int `json:"height"`240}241242type tileManifest struct {243 TileSize int `json:"tileSize"`244 Size int `json:"size"`245 Lenses []tileLensManifest `json:"lenses"`246}247248type tileLensManifest struct {249 SourcePath string `json:"sourcePath"`250 AssetPath string `json:"assetPath"`251 Stamp string `json:"stamp"`252 MinZoom int `json:"minZoom"`253 MaxZoom int `json:"maxZoom"`254 FullZoom int `json:"fullZoom"`255 SourceZoom int `json:"sourceZoom"`256 Grid worldGrid `json:"grid"`257 Formats []string `json:"formats"`258 Bounds *contentBounds `json:"bounds"`259 Interpolate bool `json:"interpolate"`260 Background string `json:"background"`261 Coverage map[string]*levelCoverage `json:"coverage"`262 // Name and AlignedWith mark a pyramid another source's raster was263 // resampled into this map's world from: it attaches as an additional264 // variant of whichever map draws the layer AlignedWith names.265 Name string `json:"name"`266 AlignedWith string `json:"alignedWith"`267}268269// catalogLink is a cross-reference the source wrote as a mapgenie URL, resolved270// to a location in this same map. Atlas is offline, so the URL itself is271// dropped and only the in-catalog target survives.272type catalogLink struct {273 Title string `json:"title"`274 LocationID int64 `json:"locationId"`275}276277var errWorldNotReady = errors.New("world is not ready for embedding")278279// preferredWorldOrder keeps a game's primary map ahead of secondary areas.280// Unlisted maps follow in title order, so adding one entry is enough to curate281// a game without restating its complete archive.282var preferredWorldOrder = map[string][]string{283 "fallout-new-vegas": {"mojave-wasteland"},284}285286// newestFirstWorlds marks the games whose maps are dated captures of one287// ground: a version history. The picker's first entry is the map the viewer288// opens, and a version history should open on the present, so these games289// sort their date-titled maps newest first.290var newestFirstWorlds = map[string]bool{291 "bend-or": true,292}293294// The raster beneath an icon decides which outline is legible, so this is295// declared rather than derived. A game whose maps are all drawn the same way296// says so once; a single map that differs from its game overrides it.297var iconOutsetByVolume = map[string]string{298 "clair-obscur-expedition-33": "dark",299 "cyberpunk-2077": "dark", // Night City's neon-on-black art, either source300 "fallout-new-vegas": "dark", // pale Pip-Boy rasters throughout301 "fallout76": "dark", // a parchment survey map, pale throughout302 "la-noire": "dark",303 "mars": "dark", // Viking's pale ochre and MOLA's bright relief wash out a light rim304 "sonic-frontiers": "dark",305}306307var iconOutsetByWorld = map[int64]string{308 3: "dark", // Skyrim309 18: "dark", // Solstheim310}311312func iconOutsetFor(raw rawMap) string {313 if outset, ok := iconOutsetByWorld[raw.ID]; ok {314 return outset315 }316 return iconOutsetByVolume[raw.Game.Slug]317}318319func main() {320 source := flag.String("source", "", "path containing fmg-archive")321 tiles := flag.String("tiles", "", "generated tile manifest")322 bundles := flag.String("bundles", "",323 "registry directory receiving .atlas bundles (default: the application's own library)")324 flag.Parse()325326 if *source == "" || *tiles == "" {327 fmt.Fprintln(os.Stderr, "generate: -source and -tiles are required")328 os.Exit(2)329 }330 if *bundles == "" {331 // Installing straight into the application's library is the default:332 // a dev run then serves the freshest builds with no pointing, and a333 // running application watching the directory picks them up live.334 library, err := bundle.DefaultDir()335 if err != nil {336 fmt.Fprintln(os.Stderr, "generate: resolving the bundle library:", err)337 os.Exit(1)338 }339 *bundles = library340 }341 if err := run(*source, *tiles, *bundles); err != nil {342 fmt.Fprintln(os.Stderr, "generate:", err)343 os.Exit(1)344 }345}346347func run(source, tileManifestPath, bundleDir string) error {348 archiveRoot := filepath.Join(source, "fmg-archive")349 var index archive350 if err := readJSON(filepath.Join(archiveRoot, "archive.json"), &index); err != nil {351 return err352 }353 var tiles tileManifest354 if err := readJSON(tileManifestPath, &tiles); err != nil {355 return err356 }357 tilesByPath := make(map[string]tileLensManifest, len(tiles.Lenses))358 alignedWith := make(map[string][]tileLensManifest)359 for _, variant := range tiles.Lenses {360 if variant.AlignedWith != "" {361 alignedWith[variant.AlignedWith] = append(alignedWith[variant.AlignedWith], variant)362 continue363 }364 tilesByPath[variant.SourcePath] = variant365 }366367 out := catalog{368 TileGrid: tileGrid{369 SourceZoom: 13,370 FirstTile: 4064,371 TileSize: tiles.TileSize,372 Size: tiles.Size,373 },374 }375 for _, gameRef := range index.Games {376 game, err := buildVolume(archiveRoot, tilesByPath, alignedWith, gameRef, out.TileGrid)377 if err != nil {378 return fmt.Errorf("%s: %w", gameRef.Title, err)379 }380 if len(game.Worlds) > 0 {381 out.Volumes = append(out.Volumes, game)382 }383 }384385 merged, err := mergeAcrossSources(out.Volumes, out.TileGrid)386 if err != nil {387 return err388 }389 out.Volumes = merged390391 for index := range out.Volumes {392 if err := resolveStandardIcons(&out.Volumes[index]); err != nil {393 return fmt.Errorf("%s: %w", out.Volumes[index].Slug, err)394 }395 }396397 sort.Slice(out.Volumes, func(i, j int) bool { return out.Volumes[i].Title < out.Volumes[j].Title })398 return writeBundles(out, tiles, filepath.Dir(tileManifestPath), bundleDir)399}400401func buildVolume(402 archiveRoot string,403 tilesByPath map[string]tileLensManifest,404 alignedWith map[string][]tileLensManifest,405 ref archiveGame,406 grid tileGrid,407) (catalogVolume, error) {408 gamePath := filepath.Join(archiveRoot, ref.Directory)409 mapDirs, err := filepath.Glob(filepath.Join(gamePath, "maps", "*"))410 if err != nil {411 return catalogVolume{}, err412 }413 game := catalogVolume{ID: ref.ID, Title: ref.Title}414 for _, mapDir := range mapDirs {415 info, err := os.Stat(mapDir)416 if err != nil || !info.IsDir() {417 continue418 }419 pieces, gameSlug, err := buildWorld(mapDir, tilesByPath, alignedWith, grid)420 if err != nil {421 if errors.Is(err, errWorldNotReady) {422 continue423 }424 return catalogVolume{}, err425 }426 if game.Slug == "" {427 game.Slug = gameSlug428 }429 // Every map opens its account with where it came from, merged with430 // anything or not: provenance is part of the map, not a side effect431 // of composition. The account carries both spellings of the source:432 // the label a person reads and the canonical slug the workbench's433 // registry names it by, so a ledger line and a plugin card point at434 // each other without a translation table.435 for index := range pieces {436 pieces[index].Merged = []mergedSource{{437 Source: sourceDisplayLabel(ref.Source),438 Slug: canonicalSourceSlug(ref.Source),439 Origin: true,440 DonorFeatures: pieces[index].featureTally(),441 }}442 }443 game.Worlds = append(game.Worlds, pieces...)444 }445 sortVolumeWorlds(game.Slug, game.Worlds)446 if err := attachVolumeIcons(gamePath, &game); err != nil {447 return catalogVolume{}, err448 }449 if err := speakConventions(&game); err != nil {450 return catalogVolume{}, fmt.Errorf("%s: %w", game.Slug, err)451 }452 return game, nil453}454455// speakConventions makes every map answer in the shared vocabulary,456// whatever its source knew how to say. A capture that declared its457// attributes keeps them; one that predates the conventions -- every458// MapGenie snapshot -- has them spoken for it from the same rules that459// used to be unwritten: the display type it carried, the icon it resolved460// to, the outset its curation chose. Then the whole game is held to the461// registry, so an unregistered key or a foreign value fails here, one462// build old, rather than riding into a bundle.463func speakConventions(game *catalogVolume) error {464 for mapIndex := range game.Worlds {465 m := &game.Worlds[mapIndex]466 if m.IconOutset != "" {467 m.Attrs = withAttr(m.Attrs, semconv.KeyIconOutset, m.IconOutset)468 }469 if err := semconv.Validate(semconv.EntityWorld, m.Attrs); err != nil {470 return fmt.Errorf("world %s: %w", m.Slug, err)471 }472 for collectionIndex := range m.Collections {473 collection := &m.Collections[collectionIndex]474 // Every collection's attributes mirror its kind, so the payload475 // says in the registered vocabulary what the wire's own field476 // says beside it, and a reader may trust either.477 if _, declared := collection.Attrs[semconv.KeyGeometryKind]; !declared {478 collection.Attrs = withAttr(collection.Attrs, semconv.KeyGeometryKind, collection.Kind)479 }480 if collection.Kind != kindPoint {481 if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {482 return fmt.Errorf("world %s collection %q: %w", m.Slug, collection.Title, err)483 }484 for _, shape := range collection.Features {485 if err := semconv.Validate(semconv.EntityFeature, shape.Attrs); err != nil {486 return fmt.Errorf("world %s zone %q: %w", m.Slug, shape.Title, err)487 }488 }489 continue490 }491 if _, declared := collection.Attrs[semconv.KeyRenderAs]; !declared {492 collection.Attrs = withAttr(collection.Attrs, semconv.KeyRenderAs,493 semconv.RenderAs(nil, collection.DisplayType))494 }495 // Where two sources are known to spell one concept two ways,496 // the shared name rides the collection itself, so the merge497 // reads identity off the payload rather than a table of its498 // own.499 if shared := categoryEquivalents[game.Slug][collection.Icon]; shared != "" {500 if _, declared := collection.Attrs[semconv.KeyCollectionKey]; !declared {501 collection.Attrs = withAttr(collection.Attrs, semconv.KeyCollectionKey, shared)502 }503 }504 if collection.IconAsset != "" {505 kind := semconv.IconKindGlyph506 if collection.IconPicture {507 kind = semconv.IconKindPicture508 }509 collection.Attrs = withAttr(collection.Attrs, semconv.KeyIconKind, kind)510 }511 if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {512 return fmt.Errorf("world %s category %q: %w", m.Slug, collection.Title, err)513 }514 for _, pin := range collection.Features {515 if err := semconv.Validate(semconv.EntityFeature, pin.Attrs); err != nil {516 return fmt.Errorf("world %s pin %q: %w", m.Slug, pin.Title, err)517 }518 }519 }520 }521 return nil522}523524// withAttr sets one attribute on a copy of the map, never the map itself: a525// split sheet's pieces share their source's attributes by reference, and526// speaking for one piece must not put words in another's mouth.527func withAttr(attrs map[string]string, key, value string) map[string]string {528 out := make(map[string]string, len(attrs)+1)529 maps.Copy(out, attrs)530 out[key] = value531 return out532}533534// resolveStandardIcons makes good on every atlas.icon.std declaration: the535// named library glyph lands in the game's icon set under its provenance-536// spelling name, and the category wears it exactly the way it would wear a537// source's own artwork. It runs after composition so merged-in categories538// resolve too, and only where a source's own icon has not already won the539// slot. A declaration the library cannot answer fails the build: the540// promise was made in a translator, and it is kept here or heard about.541func resolveStandardIcons(game *catalogVolume) error {542 for mapIndex := range game.Worlds {543 m := &game.Worlds[mapIndex]544 for collectionIndex := range m.Collections {545 collection := &m.Collections[collectionIndex]546 if collection.Kind != kindPoint {547 continue548 }549 ref := collection.Attrs[semconv.KeyIconStd]550 if ref == "" || collection.IconAsset != "" {551 continue552 }553 data, asset, err := icons.Standard(ref)554 if err != nil {555 return fmt.Errorf("world %s category %q: %w", m.Slug, collection.Title, err)556 }557 if game.Icons == nil {558 game.Icons = make(map[string][]byte)559 }560 game.Icons[asset] = data561 collection.IconAsset = asset562 collection.IconPicture = false563 collection.Attrs = withAttr(collection.Attrs, semconv.KeyIconKind, semconv.IconKindGlyph)564 }565 }566 return nil567}568569func sortVolumeWorlds(gameSlug string, maps []catalogWorld) {570 order := make(map[string]int)571 for index, slug := range preferredWorldOrder[gameSlug] {572 order[slug] = index573 }574 // A version-history game reads its date titles backward: the newest575 // capture opens first, and the past waits one click below it.576 after := func(a, b string) bool { return a < b }577 if newestFirstWorlds[gameSlug] {578 after = func(a, b string) bool { return a > b }579 }580 titles := make(map[string]string, len(maps))581 for _, m := range maps {582 titles[m.Slug] = m.Title583 }584 // Maps sort as families: an inset carries its parent's position and follows585 // it, so a split sheet stays together in the picker.586 family := func(m catalogWorld) (string, string) {587 if m.Parent == "" {588 return m.Slug, m.Title589 }590 return m.Parent, titles[m.Parent]591 }592 sort.SliceStable(maps, func(i, j int) bool {593 leftSlug, leftTitle := family(maps[i])594 rightSlug, rightTitle := family(maps[j])595 left, leftPreferred := order[leftSlug]596 right, rightPreferred := order[rightSlug]597 if leftPreferred != rightPreferred {598 return leftPreferred599 }600 if leftPreferred && left != right {601 return left < right602 }603 if leftTitle != rightTitle {604 return after(leftTitle, rightTitle)605 }606 if (maps[i].Parent == "") != (maps[j].Parent == "") {607 return maps[i].Parent == ""608 }609 return after(maps[i].Title, maps[j].Title)610 })611}612613// buildWorld returns one entry per map, or several when a sheet holding separate614// places is declared for splitting.615func buildWorld(616 mapDir string,617 tilesByPath map[string]tileLensManifest,618 alignedWith map[string][]tileLensManifest,619 grid tileGrid,620) ([]catalogWorld, string, error) {621 var snapshots []snapshotIndex622 indexPath := filepath.Join(mapDir, "snapshots", "index.json")623 if err := readJSON(indexPath, &snapshots); err != nil {624 if errors.Is(err, os.ErrNotExist) {625 return nil, "", fmt.Errorf("%w: snapshot index is missing", errWorldNotReady)626 }627 return nil, "", err628 }629 if len(snapshots) == 0 {630 return nil, "", fmt.Errorf("%w: snapshot index is empty", errWorldNotReady)631 }632 sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].CapturedAt < snapshots[j].CapturedAt })633 latest := snapshots[len(snapshots)-1]634635 // A capture from another source is translated into the MapGenie shape on636 // the way in; a MapGenie snapshot passes through untouched.637 doc, err := os.ReadFile(filepath.Join(mapDir, "snapshots", "map", latest.ContentHash+".json"))638 if err != nil {639 return nil, "", err640 }641 if doc, err = ignmap.MaybeTranslate(latest.Kind, doc); err != nil {642 return nil, "", err643 }644 if doc, err = pbmap.MaybeTranslate(latest.Kind, doc); err != nil {645 return nil, "", err646 }647 if doc, err = trekmap.MaybeTranslate(latest.Kind, doc); err != nil {648 return nil, "", err649 }650 if doc, err = arcgismap.MaybeTranslate(latest.Kind, doc); err != nil {651 return nil, "", err652 }653 var raw rawMap654 if err := json.Unmarshal(doc, &raw); err != nil {655 return nil, "", err656 }657658 m := catalogWorld{659 ID: raw.ID,660 Title: raw.Title,661 Slug: raw.Slug,662 IconOutset: iconOutsetFor(raw),663 Center: coordinate{Latitude: raw.InitialLatitude, Longitude: raw.InitialLongitude},664 UpdatedAt: latest.CapturedAt,665 Attrs: raw.Attrs,666 }667 if len(raw.Config.TileSets) == 0 {668 return nil, "", fmt.Errorf("%w: no tile sets", errWorldNotReady)669 }670 // The catalog's grid is the window most maps are cut from; this map's own671 // takes its place below, and what is left of the shared one -- the size of672 // the world and of a tile, which no map differs on -- carries through.673 shared := grid674 for _, set := range raw.Config.TileSets {675 tiles, ok := tilesByPath[set.Path]676 if !ok {677 return nil, "", fmt.Errorf("%w: tile layer %s is missing", errWorldNotReady, set.Path)678 }679 // Every layer of a map is a picture of the same ground, so they agree on680 // the window it is cut from. One that does not would be a map drawn in681 // two places at once, and no pin could be placed on it.682 window := tiles.Grid683 if window == (worldGrid{}) {684 // An index written before layers carried their window: the shared685 // one is what every map in it was cut from.686 window = worldGrid{SourceZoom: shared.SourceZoom, FirstTile: shared.FirstTile}687 }688 if len(m.Lenses) == 0 {689 grid.SourceZoom, grid.FirstTile = window.SourceZoom, window.FirstTile690 if window != (worldGrid{SourceZoom: shared.SourceZoom, FirstTile: shared.FirstTile}) {691 m.Grid = &window692 }693 } else if window.SourceZoom != grid.SourceZoom || window.FirstTile != grid.FirstTile {694 return nil, "", fmt.Errorf("%w: tile layer %s sits in a different window from %s",695 errWorldNotReady, set.Path, raw.Config.TileSets[0].Path)696 }697 m.Lenses = append(m.Lenses, lens{698 Name: set.Name,699 Tiles: tiles.AssetPath,700 MinZoom: tiles.MinZoom,701 MaxZoom: tiles.MaxZoom,702 FullZoom: tiles.FullZoom,703 SourceZoom: tiles.SourceZoom,704 Formats: tiles.Formats,705 Bounds: tiles.Bounds,706 Interpolate: tiles.Interpolate,707 Background: tiles.Background,708 Coverage: tiles.Coverage,709 })710 }711 // Another source's raster, resampled into this map's world, arrives as712 // one more way to see the same ground. It was rendered in this map's713 // window, so it passes the same agreement every native layer passes.714 for _, set := range raw.Config.TileSets {715 for _, aligned := range alignedWith[set.Path] {716 if aligned.Grid.SourceZoom != grid.SourceZoom || aligned.Grid.FirstTile != grid.FirstTile {717 return nil, "", fmt.Errorf("aligned layer %s was rendered in a different window", aligned.SourcePath)718 }719 m.Lenses = append(m.Lenses, lens{720 Name: aligned.Name,721 Tiles: aligned.AssetPath,722 MinZoom: aligned.MinZoom,723 MaxZoom: aligned.MaxZoom,724 FullZoom: aligned.FullZoom,725 SourceZoom: aligned.SourceZoom,726 Formats: aligned.Formats,727 Bounds: aligned.Bounds,728 Interpolate: aligned.Interpolate,729 Background: aligned.Background,730 Coverage: aligned.Coverage,731 })732 }733 }734 collections, err := normalizeWorld(raw)735 if err != nil {736 return nil, "", err737 }738 m.Collections = collections739 resolveDescriptionLinks(&m)740 pieces, err := splitWorld(m, raw, grid)741 if err != nil {742 return nil, "", err743 }744 for index := range pieces {745 markSurfaces(&pieces[index], grid)746 }747 return pieces, raw.Game.Slug, nil748}749750// Labels may themselves contain a bracketed aside, as in751// "[Oh Baby! [Super Sledge]](url)", so one level of nesting is allowed.752var markdownLink = regexp.MustCompile(`\[((?:[^\[\]]|\[[^\[\]]*\])*)\]\(([^)\s]+)[^)]*\)`)753var mapgenieLocation = regexp.MustCompile(`locationIds=(\d+)`)754755// Some descriptions carry malformed markdown whose URL never sat inside a link,756// e.g. "[Boss] (Lv. 55) (https://…)". Nothing may ship a live URL, so any that757// survive link rewriting are removed outright.758var bareURL = regexp.MustCompile(`\s*\(?\s*https?://[^\s)]+\)?`)759760// resolveDescriptionLinks strips every external URL out of location761// descriptions. Atlas ships with no network, so a mapgenie or YouTube link is762// dead weight at best. Where the link pointed at another location in this same763// map, the target is kept as a structured cross-reference the viewer can764// navigate to instead.765func resolveDescriptionLinks(m *catalogWorld) {766 known := make(map[int64]bool)767 for _, collection := range m.Collections {768 if collection.Kind != kindPoint {769 continue770 }771 for _, pin := range collection.Features {772 known[pin.ID] = true773 }774 }775 for collectionIndex := range m.Collections {776 collection := &m.Collections[collectionIndex]777 if collection.Kind != kindPoint {778 continue779 }780 for pinIndex := range collection.Features {781 pin := &collection.Features[pinIndex]782 if !strings.Contains(pin.Description, "http") {783 continue784 }785 pin.Description = markdownLink.ReplaceAllStringFunc(786 pin.Description,787 func(match string) string {788 parts := markdownLink.FindStringSubmatch(match)789 label, target := parts[1], parts[2]790 id := mapgenieLocation.FindStringSubmatch(target)791 if id != nil && !strings.HasPrefix(label, "!") {792 if value, err := strconv.ParseInt(id[1], 10, 64); err == nil &&793 known[value] && value != pin.ID {794 pin.Links = append(pin.Links, catalogLink{795 Title: label,796 LocationID: value,797 })798 }799 }800 return label801 },802 )803 pin.Description = strings.TrimSpace(bareURL.ReplaceAllString(pin.Description, ""))804 }805 }806}807808func attachVolumeIcons(gamePath string, game *catalogVolume) error {809 if game.Slug == "" {810 return nil811 }812 game.Icons = make(map[string][]byte)813 copied := make(map[string]string)814 for mapIndex := range game.Worlds {815 collections := game.Worlds[mapIndex].Collections816 for collectionIndex := range collections {817 collection := &collections[collectionIndex]818 if collection.Kind != kindPoint || !validIconKey(collection.Icon) {819 continue820 }821 asset, found := copied[collection.Icon]822 if !found {823 var err error824 asset, err = readVolumeIcon(gamePath, game, collection.Icon)825 if err != nil {826 return err827 }828 copied[collection.Icon] = asset829 }830 collection.IconAsset = asset831 collection.IconPicture = strings.HasSuffix(asset, ".png")832 }833 }834 return nil835}836837// readVolumeIcon takes whichever form the archive holds. Most games publish an838// icon font that renders to SVG; some publish a marker strip instead, which839// slices into PNG. The bytes ride the game into its bundle, named relative840// to the bundle's own icons tree.841func readVolumeIcon(gamePath string, game *catalogVolume, icon string) (string, error) {842 for _, candidate := range []string{".svg", ".png"} {843 source := filepath.Join(gamePath, "icons", icon+candidate)844 contents, err := os.ReadFile(source)845 if errors.Is(err, os.ErrNotExist) {846 continue847 }848 if err != nil {849 return "", fmt.Errorf("read icon %s: %w", source, err)850 }851 game.Icons[icon+candidate] = contents852 return icon + candidate, nil853 }854 return "", nil855}856857func validIconKey(value string) bool {858 if value == "" {859 return false860 }861 for _, r := range value {862 if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' ||863 r >= '0' && r <= '9' || r == '_' || r == '-' {864 continue865 }866 return false867 }868 return true869}870871func resolvedCategoryColor(group rawGroup, category rawCategory) string {872 if color := normalizeHexColor(category.Color); color != "" {873 return color874 }875 return normalizeHexColor(group.Color)876}877878func resolvedIconColor(group rawGroup, category rawCategory) string {879 if color := normalizeHexColor(category.IconColor); color != "" {880 return color881 }882 return normalizeHexColor(group.IconColor)883}884885func normalizeHexColor(value string) string {886 value = strings.TrimPrefix(strings.TrimSpace(value), "#")887 switch len(value) {888 case 3, 4, 6, 8:889 default:890 return ""891 }892 for _, r := range value {893 if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F' {894 continue895 }896 return ""897 }898 return "#" + strings.ToUpper(value)899}900901func number(raw json.RawMessage) (float64, error) {902 value := strings.TrimSpace(string(raw))903 if len(value) >= 2 && value[0] == '"' {904 var text string905 if err := json.Unmarshal(raw, &text); err != nil {906 return 0, err907 }908 value = text909 }910 return strconv.ParseFloat(value, 64)911}912913func optionalNumber(raw json.RawMessage) (float64, bool, error) {914 value := strings.TrimSpace(string(raw))915 if value == "" || value == "null" {916 return 0, false, nil917 }918 n, err := number(raw)919 return n, err == nil, err920}921922func readJSON(path string, dst any) error {923 data, err := os.ReadFile(path)924 if err != nil {925 return fmt.Errorf("read %s: %w", path, err)926 }927 if err := json.Unmarshal(data, dst); err != nil {928 return fmt.Errorf("decode %s: %w", path, err)929 }930 return nil931}