The games do not agree on where their maps sit in the source grid: most are cut from a 32-tile square at zoom 13, but Grand Theft Auto 5 is cut from a square at the origin whose deepest level is 43 tiles across. Rather than assuming the shared window, each layer's declared bounds are halved until they collapse into a single tile, and that level is the floor its local zooms are counted from. This is what let Los Santos be captured at all; the recorded window rides the manifest so generate places the map's pins against it.
- commit
- f67dc268e2d8da6b882483c6e832036cbbce1c1c
- parent
- 58db2a6bce7f293251b7b3195e72a65fc6071e1c
- author
- Dami <dami.etoile@gmail.com>
| M | tools/ | 125 | ++++++++++++++++++++++++------ |
| M | tools/ | 53 | +++++++++++++ |
@@ -24,13 +24,70 @@ import (
2424 )2525 2626 const (27- referenceZoom = 1328- baseSourceZoom = 829- firstTile = 406430- tileSize = 25631- worldSize = 819227+ referenceZoom = 1328+ firstTile = 406429+ tileSize = 25630+ worldSize = 819231+ // worldZoomLevels is how far the world square sits above the level that32+ // holds a map in one tile: 8192 units of world across, 256 to a tile, so33+ // five halvings. It is what makes a unit of world one pixel of the34+ // reference level, whichever level that turns out to be.35+ worldZoomLevels = 53236 )3337 38+// A tile set's window is where its map sits in the source grid, and the games39+// do not agree on it. The maps captured most recently are cut from a 32-tile40+// square at zoom 13; Grand Theft Auto 5 is cut from a square at the origin41+// whose deepest level is 43 tiles across and whose shallowest is 3. Both are42+// the same kind of pyramid seen from a different height, so no one height is43+// assumed: each layer is measured, and the zoom whose window collapses into a44+// single tile is the floor its local zooms are counted from.45+type tileFrame struct {46+ // BaseZoom is the source zoom that holds the whole window in one tile, and47+ // BaseTile is that tile. Local zoom 0 is this level, so a layer's local48+ // zooms are its source zooms less BaseZoom.49+ BaseZoom int50+ BaseTile int51+}52+53+// frameFor halves a tile set's declared window until it is a single tile. A set54+// that declares nothing is measured against the shared window instead, which is55+// where every map cut from a 32-tile square at zoom 13 lands.56+func frameFor(set rawTileSet) (tileFrame, bool) {57+ bounds := expectedBounds(set, set.MaxZoom)58+ for shift := 0; shift <= set.MaxZoom; shift++ {59+ x, y := bounds.X.Min>>shift, bounds.Y.Min>>shift60+ if x != bounds.X.Max>>shift || y != bounds.Y.Max>>shift {61+ continue62+ }63+ // The viewer places a pin from one origin for both axes, so a window64+ // whose axes come to rest on different tiles would draw the map in one65+ // place and its pins in another. Nothing in the archive is shaped that66+ // way; a layer that is says so rather than being silently a tile out.67+ if x != y {68+ return tileFrame{}, false69+ }70+ return tileFrame{BaseZoom: set.MaxZoom - shift, BaseTile: x}, true71+ }72+ return tileFrame{}, false73+}74+75+// origin is the first tile of a source level: the base tile, halved back down76+// the pyramid the same way the window was.77+func (f tileFrame) origin(zoom int) int {78+ return f.BaseTile << (zoom - f.BaseZoom)79+}80+81+// grid is where this layer's world space sits in the source grid, in the terms82+// the viewer needs to place a pin: the zoom whose pixels are the world's units,83+// and the first tile of the window at that zoom. A map in the shared window84+// lands back on zoom 13 and tile 4064, which is where the numbers the viewer85+// used to assume came from.86+func (f tileFrame) grid() gridSpec {87+ reference := f.BaseZoom + worldZoomLevels88+ return gridSpec{SourceZoom: reference, FirstTile: f.origin(reference)}89+}90+3491 type archive struct {3592 Games []archiveGame `json:"games"`3693 }@@ -102,6 +159,7 @@ type tilePlan struct {
102159 // cover the map completely; deeper ones may be partially captured and are103160 // carried as extra detail on top of the complete pyramid.104161 Levels map[int][]tileFile162+ Frame tileFrame105163 Interpolate bool106164 MapDir string107165 MaxFullZoom int@@ -124,8 +182,12 @@ type variantManifest struct {
124182 // FullZoom is the deepest level with complete coverage. Levels beyond it125183 // exist only where Coverage says so; the viewer falls back to the parent126184 // tile elsewhere.127- FullZoom int `json:"fullZoom"`128- SourceZoom int `json:"sourceZoom"`185+ FullZoom int `json:"fullZoom"`186+ SourceZoom int `json:"sourceZoom"`187+ // Grid places this layer's world space in the source tile grid. Pins arrive188+ // as latitudes and longitudes in that grid, so it is what lets them be laid189+ // over the raster.190+ Grid gridSpec `json:"grid"`129191 Formats []string `json:"formats"`130192 Bounds *contentBounds `json:"bounds,omitempty"`131193 Interpolate bool `json:"interpolate"`@@ -137,6 +199,11 @@ type variantManifest struct {
137199 Stamp string `json:"stamp,omitempty"`138200 }139201 202+type gridSpec struct {203+ SourceZoom int `json:"sourceZoom"`204+ FirstTile int `json:"firstTile"`205+}206+140207 // levelCoverage is a row-major bitset over the [X,Y,W,H] tile window of one141208 // level. A set bit means the tile was written.142209 type levelCoverage struct {@@ -259,6 +326,10 @@ func run(source, output string, force bool) error {
259326 for _, plan := range plans {260327 stamp := planStamp(plan)261328 if kept, ok := carry(built, plan, stamp, output, force); ok {329+ // The window is read from the archive rather than derived330+ // from the pyramid, so it is answered from the plan even331+ // for a layer nothing has changed under.332+ kept.Grid = plan.Frame.grid()262333 out.Variants = append(out.Variants, kept)263334 carried++264335 continue@@ -398,10 +469,14 @@ func inspectMap(mapDir string) ([]tilePlan, string, string, error) {
398469 399470 plans := make([]tilePlan, 0, len(raw.Config.TileSets))400471 for _, set := range raw.Config.TileSets {472+ frame, ok := frameFor(set)473+ if !ok {474+ return nil, raw.Title, fmt.Sprintf("layer %q has no square window", set.Name), nil475+ }401476 levels := make(map[int][]tileFile)402477 maxFullZoom, maxSourceZoom := -1, -1403478 for zoom, level := range byPath[set.Path] {404- if zoom < baseSourceZoom || zoom > set.MaxZoom {479+ if zoom < frame.BaseZoom || zoom > set.MaxZoom {405480 continue406481 }407482 files, full, err := readLevel(mapDir, set, zoom, level)@@ -417,7 +492,7 @@ func inspectMap(mapDir string) ([]tilePlan, string, string, error) {
417492 maxFullZoom = max(maxFullZoom, zoom)418493 }419494 }420- if maxFullZoom < baseSourceZoom {495+ if maxFullZoom < frame.BaseZoom {421496 return nil, raw.Title, fmt.Sprintf("layer %q has no complete source level", set.Name), nil422497 }423498 // A partial level is only usable if every level beneath it exists;@@ -434,7 +509,8 @@ func inspectMap(mapDir string) ([]tilePlan, string, string, error) {
434509 }435510 plans = append(plans, tilePlan{436511 AssetPath: assetPath,437- Bounds: contentBoundsFor(levels[maxFullZoom], maxFullZoom),512+ Bounds: contentBoundsFor(levels[maxFullZoom], maxFullZoom, frame),513+ Frame: frame,438514 Levels: levels,439515 Interpolate: !isPixelArt(raw.Game.Slug),440516 MapDir: mapDir,@@ -507,8 +583,8 @@ func worldTileRange(zoom int) (int, int) {
507583 }508584 509585 func buildPyramid(root string, plan tilePlan) (variantManifest, error) {510- maxZoom := plan.MaxSourceZoom - baseSourceZoom511- fullZoom := plan.MaxFullZoom - baseSourceZoom586+ maxZoom := plan.MaxSourceZoom - plan.Frame.BaseZoom587+ fullZoom := plan.MaxFullZoom - plan.Frame.BaseZoom512588 formats := make([]string, maxZoom+1)513589 coverage := make(map[string]*levelCoverage)514590 @@ -561,7 +637,7 @@ func buildPyramid(root string, plan tilePlan) (variantManifest, error) {
561637 // level that survives with nothing in it ends the pyramid: advertising a562638 // zoom with no tiles would only produce misses.563639 for localZoom := fullZoom + 1; localZoom <= maxZoom; localZoom++ {564- sourceZoom := localZoom + baseSourceZoom640+ sourceZoom := localZoom + plan.Frame.BaseZoom565641 format, mask, err := copyLevel(root, plan, sourceZoom, localZoom, plan.Levels[sourceZoom], placeholder)566642 if err != nil {567643 return variantManifest{}, err@@ -585,6 +661,7 @@ func buildPyramid(root string, plan tilePlan) (variantManifest, error) {
585661 MaxZoom: maxZoom,586662 FullZoom: fullZoom,587663 SourceZoom: plan.MaxSourceZoom,664+ Grid: plan.Frame.grid(),588665 Formats: formats,589666 Bounds: plan.Bounds,590667 Interpolate: plan.Interpolate,@@ -627,7 +704,7 @@ func copyLevel(
627704 return "", nil, fmt.Errorf("source level %d has no tiles", sourceZoom)628705 }629706 outputFormat := files[0].Format630- origin, _ := worldTileRange(sourceZoom)707+ origin := plan.Frame.origin(sourceZoom)631708 span := 1 << localZoom632709 builder := &coverageBuilder{total: span * span}633710 @@ -825,7 +902,7 @@ func downsample(source *image.NRGBA, nearest bool) *image.NRGBA {
825902 return target826903 }827904 828-func contentBoundsFor(files []tileFile, zoom int) *contentBounds {905+func contentBoundsFor(files []tileFile, zoom int, frame tileFrame) *contentBounds {829906 counts := make(map[string]int)830907 var placeholder string831908 for _, file := range files {@@ -850,15 +927,17 @@ func contentBoundsFor(files []tileFile, zoom int) *contentBounds {
850927 if maxX < minX || maxY < minY {851928 return nil852929 }853- // Bounds are reported in the shared world space, which is sized at the854- // reference zoom. A shallower level covers more world per tile and a deeper855- // one covers less, so the tile pitch is scaled both ways.856- origin, _ := worldTileRange(zoom)930+ // Bounds are reported in world space, which is sized at this layer's own931+ // reference zoom -- the level whose pixels are its units. A shallower level932+ // covers more world per tile and a deeper one covers less, so the tile pitch933+ // is scaled both ways.934+ reference := frame.BaseZoom + worldZoomLevels935+ origin := frame.origin(zoom)857936 pitch, divisor := tileSize, 1858- if zoom < referenceZoom {859- pitch = tileSize << (referenceZoom - zoom)860- } else if zoom > referenceZoom {861- divisor = 1 << (zoom - referenceZoom)937+ if zoom < reference {938+ pitch = tileSize << (reference - zoom)939+ } else if zoom > reference {940+ divisor = 1 << (zoom - reference)862941 }863942 bounds := &contentBounds{864943 X: (minX - origin) * pitch / divisor,@@ -35,6 +35,59 @@ func TestExpectedBoundsUsesConfiguredLayerBounds(t *testing.T) {
3535 }3636 }3737 38+// The two windows the archive holds. A layer that declares nothing is cut from39+// the shared square at zoom 13, and lands on the base and world grid the viewer40+// assumed before any of this was measured; GTA 5 declares its own, five levels41+// shallower and anchored at the origin.42+func TestFrameForMeasuresEachWindow(t *testing.T) {43+ tests := []struct {44+ name string45+ set rawTileSet46+ wantBase int47+ wantGrid gridSpec48+ }{49+ {50+ name: "the shared window",51+ set: rawTileSet{MaxZoom: 15},52+ wantBase: 8,53+ wantGrid: gridSpec{SourceZoom: 13, FirstTile: 4064},54+ },55+ {56+ name: "a window of its own",57+ set: rawTileSet{MaxZoom: 7, Bounds: map[string]rawTileSetBound{58+ "7": {X: rawRange{Min: 0, Max: 42}, Y: rawRange{Min: 0, Max: 42}},59+ }},60+ wantBase: 1,61+ wantGrid: gridSpec{SourceZoom: 6, FirstTile: 0},62+ },63+ }64+ for _, test := range tests {65+ t.Run(test.name, func(t *testing.T) {66+ frame, ok := frameFor(test.set)67+ if !ok {68+ t.Fatal("frameFor() found no window")69+ }70+ if frame.BaseZoom != test.wantBase {71+ t.Errorf("BaseZoom = %d, want %d", frame.BaseZoom, test.wantBase)72+ }73+ if got := frame.grid(); got != test.wantGrid {74+ t.Errorf("grid() = %#v, want %#v", got, test.wantGrid)75+ }76+ })77+ }78+}79+80+// A window that is one tile on one axis and two on the other would place pins81+// against an origin it does not have.82+func TestFrameForRejectsAnUnsquareWindow(t *testing.T) {83+ set := rawTileSet{MaxZoom: 4, Bounds: map[string]rawTileSetBound{84+ "4": {X: rawRange{Min: 0, Max: 3}, Y: rawRange{Min: 8, Max: 11}},85+ }}86+ if _, ok := frameFor(set); ok {87+ t.Fatal("frameFor() accepted a window whose axes rest on different tiles")88+ }89+}90+3891 func TestTileSetPath(t *testing.T) {3992 const rawURL = "https://tiles.mapgenie.io/games/skyrim/skyrim/default-v1/13/4076/4064.jpg"4093 if got, want := tileSetPath(rawURL, 13), "skyrim/skyrim/default-v1"; got != want {