Every pyramid was derived afresh on every run. Capturing one new game meant re-decoding and re-reducing the other seventeen -- half a minute of work to arrive at the bytes that were already on disk -- because the tool built the whole tree into a temporary directory and swapped it in wholesale. A layer is derived from tiles the archive already stores under their content hashes, so what went into a pyramid can be written down. Each one now carries a stamp over the hash of every source tile, the shape the plan settled on, and the source of the tool itself: changing how a level is reduced or where the content bounds land has to invalidate everything, and a stamp that only watched the archive would quietly keep serving the old derivation. A layer whose stamp still matches is left exactly where it is. That means installing per layer rather than swapping the tree: what was derived is moved into place by one rename each, layers the archive no longer offers are taken out, and the index is written last. A run that dies partway leaves an index naming the old stamps for anything it had replaced, so the next run finds the mismatch and derives those layers again. Deriving all thirty-one layers takes 32s; a run with nothing to do now takes 2.2s, all of it reading the archive's own indexes to work out that there is nothing to do. Rebuilding this repository's tiles from scratch under the new code changed no tile byte -- only the index, which now carries the stamps. `-force` derives everything again for when that is what is wanted.
- commit
- c428b04d5077690ad13725f5fc3c9425a1eef586
- parent
- 88b67d9c2380ebe5b6fc0f280057018a65da4eea
- author
- Dami <dami.etoile@gmail.com>
| M | .gitignore | 1 | + |
| M | README.md | 5 | + |
| M | assets/ | 2 | +- |
| M | tools/ | 117 | +++++++++++++++++----- |
| A | tools/ | 135 | ++++++++++++++++++++++++++ |
| A | tools/ | 157 | ++++++++++++++++++++++++++++++ |
@@ -63,6 +63,11 @@ go generate .
6363 normalized to lossless PNG and use nearest-neighbor reduction. Photo6464 pyramids use smooth box reduction. Repeated placeholder borders are6565 excluded from fit bounds.66+67+ Each pyramid records a stamp over the tiles it was derived from and the68+ tool that derived them, so a layer nothing has changed under is left where69+ it is: a run that captures one new game costs seconds rather than the half70+ minute of re-deriving the rest. `-force` derives everything again.6671 2. `tools/generate` builds `assets/catalog.json` and copies referenced archive6772 SVGs into `assets/icons`. Maps with missing snapshots or incomplete6873 configured layers are omitted.@@ -1 +1 @@
1-{"tileSize":256,"size":8192,"variants":[{"sourcePath":"clair-obscur-expedition-33/the-continent/default-v10","assetPath":"clair-obscur-expedition-33__the-continent","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1024,"y":512,"width":5888,"height":5888},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"/08="},"3":{"x":1,"y":0,"w":6,"h":7,"bits":"wb7/v4cB"},"4":{"x":2,"y":1,"w":12,"h":12,"bits":"ATAQh+d4juf/////+M8/vAMY"},"5":{"x":4,"y":2,"w":23,"h":23,"bits":"AwCAAQDAAADgAGBgA/jxAf7wAf/gAD9wgB8+wA/f/4fv/8/3/7f/9/v//9D//wD/Z8B/Pvz/A/7/Aa94AAIMAAAGAA=="}}},{"sourcePath":"fallout-76/appalachia/default-v4","assetPath":"fallout76__appalachia","minZoom":0,"maxZoom":7,"fullZoom":2,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#1d1d1d","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="},"6":{"x":16,"y":16,"w":32,"h":32,"bits":"//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8="},"7":{"x":32,"y":32,"w":64,"h":64,"bits":"//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8="}}},{"sourcePath":"fallout-new-vegas/big-mt/default-v1","assetPath":"fallout-new-vegas__big-mt","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"fxc="},"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wE9PT09AQE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQDxD/EP8Q/xD/EP8Q/xD/EPAQABAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}}},{"sourcePath":"fallout-new-vegas/mojave-wasteland/default-v2","assetPath":"fallout-new-vegas__mojave-wasteland","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":768,"y":768,"width":6656,"height":5376},"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":3,"bits":"/w8="},"3":{"x":0,"y":0,"w":8,"h":6,"bits":"fn7///88"},"4":{"x":1,"y":1,"w":14,"h":11,"bits":"/A//w//8///////////4B/6BfwA="},"5":{"x":3,"y":3,"w":26,"h":21,"bits":"8P8/wP//AP//A/z/D/D/P/z/////////////////////////////////////D/7/Afj/B+D/H4D/fwD+/wH4/wfg/x8A"}}},{"sourcePath":"fallout-new-vegas/sierra-madre/default-v1","assetPath":"fallout-new-vegas__sierra-madre","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"fxc="},"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wE9PT09AQE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQDxD/EP8Q/xD/EP8Q/xD/EPAQABAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}}},{"sourcePath":"fallout-new-vegas/the-divide/default-v1","assetPath":"fallout-new-vegas__the-divide","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wF/f39/fwE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQD5H/kf+R/5H/kf+R/5H/kf+R8BAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAACB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AQEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}}},{"sourcePath":"fallout-new-vegas/zion-canyon/default-v1","assetPath":"fallout-new-vegas__zion-canyon","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wF/f39/fwE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQD5H/kf+R/5H/kf+R/5H/kf+R8BAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAACB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AQEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}}},{"sourcePath":"forza-horizon-6/four/default-v2","assetPath":"forza-horizon-6__japan__winter","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}}},{"sourcePath":"forza-horizon-6/one/default-v2","assetPath":"forza-horizon-6__japan__spring","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}}},{"sourcePath":"forza-horizon-6/three/default-v2","assetPath":"forza-horizon-6__japan__autumn","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}}},{"sourcePath":"forza-horizon-6/two/default-v2","assetPath":"forza-horizon-6__japan__summer","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}}},{"sourcePath":"la-noire/los-angeles/default-v2","assetPath":"la-noire__los-angeles","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["png","png","png","png","png","png","png","png"],"bounds":{"x":0,"y":2560,"width":8192,"height":5632},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"Zg8="},"3":{"x":0,"y":2,"w":8,"h":6,"bits":"DDw8OAD/"},"4":{"x":0,"y":5,"w":16,"h":11,"bits":"8ADwA/AP4A/AD4APAAAAAAAAAAD//w=="},"5":{"x":0,"y":10,"w":32,"h":22,"bits":"AH8AAAD/AAAA/wEAAP8PAAD+PwAA/P8AAPz/AADw/wAA8P8AAPD/AACA/wAAgP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////w=="},"6":{"x":16,"y":20,"w":32,"h":24,"bits":"+AEAAP8DAAD/PwAA//8AAP//AAD+/wMA/v8HAP7/DwD8/w8A+P//B+D//w/g//8f4P//f+D//38A//9/AP//fwD+/38A/P9/APD/fwCA/38AgP9/AID//wAA//8AAP5/"},"7":{"x":38,"y":43,"w":25,"h":15,"bits":"4D8A/P8A/P8D+P8P8N8/AID/AQD8AwD4DwDAPwDA/wDg/wHA/w+A/x8A/z8AAH4="}}},{"sourcePath":"marathon/cryo-archive/default-v2","assetPath":"marathon__cryo-archive","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":2304,"width":5120,"height":3584},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"////"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"//7///////9//w=="},"5":{"x":6,"y":9,"w":20,"h":14,"bits":"5//8/7////////////////////////////////////85/+8="}}},{"sourcePath":"marathon/dire-marsh/default-v2","assetPath":"marathon__dire-marsh","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":2048,"width":4608,"height":3840},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"3v9/"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"4PH3//////ePAw=="},"5":{"x":7,"y":8,"w":18,"h":15,"bits":"AHgA+AP+H/x//v/9////////////////3/8//3/4f8ADAA=="}}},{"sourcePath":"marathon/outpost/default-v1","assetPath":"marathon__outpost","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2304,"y":2304,"width":3584,"height":3584},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"HH7+////fz4="},"5":{"x":9,"y":9,"w":14,"h":14,"bits":"+AH/4f/4f/6///////////////9//g//AQ=="}}},{"sourcePath":"marathon/perimeter/default-v5","assetPath":"marathon__perimeter","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":1536,"width":4608,"height":4864},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"9m8="},"3":{"x":1,"y":1,"w":6,"h":6,"bits":"3v//ngM="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"/Pj///////+/f/54AQ=="},"5":{"x":7,"y":6,"w":18,"h":19,"bits":"8B/A/8D/D/////////////////////f////////7/8//H/8//D/w/8CPAQ=="}}},{"sourcePath":"old-school-runescape/runescape-surface/default-v6","assetPath":"old-school-runescape__runescape-surface","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1024,"y":2304,"width":6144,"height":4352},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"/w8="},"3":{"x":1,"y":2,"w":6,"h":5,"bits":"////Pw=="},"4":{"x":2,"y":4,"w":12,"h":9,"bits":"/////////////////w8="},"5":{"x":4,"y":9,"w":24,"h":17,"bits":"////////////////////////////////////////////////////////////////////"},"6":{"x":8,"y":18,"w":47,"h":33,"bits":"/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////38="},"7":{"x":16,"y":36,"w":94,"h":66,"bits":"/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////w8="}}},{"sourcePath":"palworld/1-0/default-v1","assetPath":"palworld__palpagos-islands","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":5632,"height":6656},"interpolate":true,"background":"#0d151e","coverage":{"2":{"x":0,"y":0,"w":3,"h":4,"bits":"/w8="},"3":{"x":0,"y":0,"w":6,"h":7,"bits":"3/f/v+8B"},"4":{"x":0,"y":0,"w":11,"h":13,"bits":"//nPf/7zn//73/z3xz/+8Ycf"},"5":{"x":0,"y":0,"w":22,"h":26,"bits":"//8BD2Dw/xj8fwb/v8H/f+D/H/j/B/7/gf9/4P8P8P9//Pk/P/7Pg/9z8P8f/j+A/w/w/wP+f4D/H+D/A/h/AP4fgP8D4D8A"}}},{"sourcePath":"pokemon-firered-leafgreen/kanto/firered-v2","assetPath":"pokemon-firered-leafgreen__kanto__firered","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false},{"sourcePath":"pokemon-firered-leafgreen/kanto/leafgreen-v3","assetPath":"pokemon-firered-leafgreen__kanto__leafgreen","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false},{"sourcePath":"pokemon-yellow/kanto/yellow-v1","assetPath":"pokemon-red-blue-yellow__yellow","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false},{"sourcePath":"skyrim/skyrim/default-v1","assetPath":"skyrim__skyrim","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":3840,"height":3072},"interpolate":true,"coverage":{"1":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"2":{"x":0,"y":0,"w":2,"h":2,"bits":"Dw=="},"3":{"x":0,"y":0,"w":4,"h":3,"bits":"/w8="},"4":{"x":0,"y":0,"w":8,"h":6,"bits":"////////"},"5":{"x":0,"y":0,"w":15,"h":12,"bits":"/////////////////////////////w8="}}},{"sourcePath":"skyrim/solstheim/default-v1","assetPath":"skyrim__solstheim","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":1536,"height":1536},"interpolate":true,"coverage":{"1":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"2":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"3":{"x":0,"y":0,"w":2,"h":2,"bits":"Dw=="},"4":{"x":0,"y":0,"w":3,"h":3,"bits":"/wE="},"5":{"x":0,"y":0,"w":6,"h":6,"bits":"/////w8="}}},{"sourcePath":"sonic-frontiers/ares-island/default-v1","assetPath":"sonic-frontiers__ares-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}}},{"sourcePath":"sonic-frontiers/chaos-island/default-v1","assetPath":"sonic-frontiers__chaos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}}},{"sourcePath":"sonic-frontiers/kronos-island/default-v1","assetPath":"sonic-frontiers__kronos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}}},{"sourcePath":"sonic-frontiers/ouranos-island/default-v1","assetPath":"sonic-frontiers__ouranos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}}},{"sourcePath":"temtem/airborne-archipelago/default-v11","assetPath":"temtem__airborne-archipelago","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","png"],"bounds":{"x":512,"y":2560,"width":6400,"height":5120},"interpolate":true,"background":"#002344","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"/w8="},"3":{"x":0,"y":2,"w":7,"h":6,"bits":"fr/ffzcA"},"4":{"x":1,"y":5,"w":13,"h":10,"bits":"zs7/+T//5/74nv//fB+AAQA="},"5":{"x":2,"y":10,"w":25,"h":20,"bits":"4MAA8MH54A3wg///h///Dz48Hz7/Pv7+Pfzge+A55wB2HADv+If//5//+z9/4L/+AAv/AQD6AQDAAwAABwAA"}}},{"sourcePath":"tunic/world/default-v2","assetPath":"tunic__world","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#000000","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/v//v/9///s="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"DD+84CJv/h/+ffjv88/4DfJ/+//jP64bzkt+/8/jQDw="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"AABgAHAABg/wBgBggMEAOAwMzgAAADAYwPGBAdz/fwDA/3Me/P/zCcD/f2DA23cMDv7/AAD//2DA//MAwP8jAAD+/zEM+J8HAP7fO475/8cO+OMHAPjnB2AAxwMYjAcAwODHABiABTD4H8wB4Ac+eP9wAHgAAAYAAADwDwAwAAA="},"6":{"x":26,"y":25,"w":8,"h":5,"bits":"//////8="},"7":{"x":54,"y":51,"w":10,"h":6,"bits":"Dvzz///+ww8="}}},{"sourcePath":"zelda-breath-of-the-wild/hyrule/default-v1","assetPath":"zelda-breath-of-the-wild__hyrule","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":2048,"width":4608,"height":4096},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"////"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"/////////////w=="},"5":{"x":7,"y":8,"w":18,"h":16,"bits":"////////////////////////////////////////////////"}}},{"sourcePath":"zelda-tears-of-the-kingdom/hyrule/default-v2","assetPath":"zelda-tears-of-the-kingdom__hyrule","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1024,"width":4864,"height":5888},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"Zm8="},"3":{"x":1,"y":1,"w":6,"h":6,"bits":"nud9PAM="},"4":{"x":3,"y":2,"w":10,"h":12,"bits":"/vjHDz/88ffPP/jjhwce"},"5":{"x":6,"y":4,"w":19,"h":23,"bits":"4H/A/wP+f8D/A/4P8D+A/wH8D+B/AP8f+P/A/wD+h/8//P8B/A/AvwP+P/D/gH8A/APgHwD/AA=="},"6":{"x":13,"y":8,"w":36,"h":45,"bits":"AAAeAADA//8A4P//DwD+//8A4P//DwDA//8PAPz//wDA//8PAPz/fwDA//8AAPz/DwDA//8AAPz/DwDA//8AAPz/DwDA/38AAPz/BwDA//8AAPz/7wHA//8/APz//wfA//9/APz/DwDA//8AAPz/DwDA//8A/Pz/D/D///8A////D/D///8AAPz/DwDA/38AAAAfAAAA/388APD/8w8A////APD/8wYA/z8AAPj/AwCA/z8AAPj/AwCA/z8AAPj/AwCA/z8AAPj/AwA="},"7":{"x":26,"y":17,"w":72,"h":89,"bits":"AAAAAPwDAAAAAADg////PwAAAADg////fwAAAPj/////fwAAAPj/////fwAAAJD9////fwAAAPj/////fwAAAPj/////fwAAAPj/////fwAAAADw////fwAAAADw/////38AAADw/////38AAADw////f38AAADw////f38AAADw////f38AAADw////fzYAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADg////PwAAAADA////HwAAAAAAAPwBAAAAAADg////PwAAAADg////fwAAAADw////fwAAAADw////fwAAAADw////f/gDAADw////f/wDAADw////f/4LAADw//////sdAADw//////8fAADw//////sfAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAA4H/w////fwAA4P/w////fwAAWm/9////fwAA////////fwAA////////fwAA////////fwAAAADw////fwAAAADw////fwAAAADg////fwAAAADg////PwAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAP4DAAAAAAAA////GeADAAAA////D/AHAAAA////D/AHAAAA////D9tmAAAA////n///AAAA////n/9/AAAA/v//D9kkAAAA/v//DwAAAAAA/v//DwAAAAAA/v//DwAAAAAA/v//DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////AQAAAACA////AQAAAACA////CQAAAACA////DwAAAACA/9Y9AAAA"}}}]}1+{"tileSize":256,"size":8192,"variants":[{"sourcePath":"clair-obscur-expedition-33/the-continent/default-v10","assetPath":"clair-obscur-expedition-33__the-continent","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1024,"y":512,"width":5888,"height":5888},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"/08="},"3":{"x":1,"y":0,"w":6,"h":7,"bits":"wb7/v4cB"},"4":{"x":2,"y":1,"w":12,"h":12,"bits":"ATAQh+d4juf/////+M8/vAMY"},"5":{"x":4,"y":2,"w":23,"h":23,"bits":"AwCAAQDAAADgAGBgA/jxAf7wAf/gAD9wgB8+wA/f/4fv/8/3/7f/9/v//9D//wD/Z8B/Pvz/A/7/Aa94AAIMAAAGAA=="}},"stamp":"790d4270ec5d1c1649c18e2781006e678e7339f9141f2a1307eedf4af6310e34"},{"sourcePath":"fallout-76/appalachia/default-v4","assetPath":"fallout76__appalachia","minZoom":0,"maxZoom":7,"fullZoom":2,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#1d1d1d","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="},"6":{"x":16,"y":16,"w":32,"h":32,"bits":"//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8="},"7":{"x":32,"y":32,"w":64,"h":64,"bits":"//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8="}},"stamp":"ba3dcd80bb427b46e81e81f9cb1550f4dcb55b1840a87a41a6a411489b4829b2"},{"sourcePath":"fallout-new-vegas/big-mt/default-v1","assetPath":"fallout-new-vegas__big-mt","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"fxc="},"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wE9PT09AQE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQDxD/EP8Q/xD/EP8Q/xD/EPAQABAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}},"stamp":"950ab0988c42ac61dd5a93268aaee0e0dfba00551965952cd9ff41b2ce8ca4d3"},{"sourcePath":"fallout-new-vegas/mojave-wasteland/default-v2","assetPath":"fallout-new-vegas__mojave-wasteland","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":768,"y":768,"width":6656,"height":5376},"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":3,"bits":"/w8="},"3":{"x":0,"y":0,"w":8,"h":6,"bits":"fn7///88"},"4":{"x":1,"y":1,"w":14,"h":11,"bits":"/A//w//8///////////4B/6BfwA="},"5":{"x":3,"y":3,"w":26,"h":21,"bits":"8P8/wP//AP//A/z/D/D/P/z/////////////////////////////////////D/7/Afj/B+D/H4D/fwD+/wH4/wfg/x8A"}},"stamp":"7133ec9987fe543ac217e676502939dc42fe794d55e2c61b54e18a82b8d530f1"},{"sourcePath":"fallout-new-vegas/sierra-madre/default-v1","assetPath":"fallout-new-vegas__sierra-madre","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"fxc="},"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wE9PT09AQE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQDxD/EP8Q/xD/EP8Q/xD/EPAQABAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAf//AAH//wAB//8AAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}},"stamp":"06c5e401037b2462218edcf344d1b6f997606e0b025534163dd82f93dbffd614"},{"sourcePath":"fallout-new-vegas/the-divide/default-v1","assetPath":"fallout-new-vegas__the-divide","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wF/f39/fwE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQD5H/kf+R/5H/kf+R/5H/kf+R8BAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAACB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AQEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}},"stamp":"0315909b0f0761254fe624e75ef1f5d4e5b29f0e5ab275b8fa6c3248a16f6c18"},{"sourcePath":"fallout-new-vegas/zion-canyon/default-v1","assetPath":"fallout-new-vegas__zion-canyon","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#0e0e0e","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/wF/f39/fwE="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"//8BAAEAAQD5H/kf+R/5H/kf+R/5H/kf+R8BAAEAAQA="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"/////wEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAACB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AYH//wGB//8Bgf//AQEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAA="}},"stamp":"be660211938c987acddb9e00d7c1601b9860ef73313fed1e4bc02421a6e54574"},{"sourcePath":"forza-horizon-6/four/default-v2","assetPath":"forza-horizon-6__japan__winter","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}},"stamp":"62dbf0903b86a8c0ac8bbabe4888bb8953a88e4f80dc5b48f96a8eff5f0c4327"},{"sourcePath":"forza-horizon-6/one/default-v2","assetPath":"forza-horizon-6__japan__spring","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}},"stamp":"2d104b10c42f00df63d3d7feaad3334418b8b5ba736a3b7c7a8e800efc4c072f"},{"sourcePath":"forza-horizon-6/three/default-v2","assetPath":"forza-horizon-6__japan__autumn","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}},"stamp":"be5499a72f86fc5bc0006621d7256011076550acc3fb7ce33b8caaa79e7bd3c0"},{"sourcePath":"forza-horizon-6/two/default-v2","assetPath":"forza-horizon-6__japan__summer","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2048,"y":2048,"width":4096,"height":4096},"interpolate":true,"background":"#232621","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"//////////8="},"5":{"x":8,"y":8,"w":16,"h":16,"bits":"//////////////////////////////////////////8="}},"stamp":"6ffad27f87cc5898306e67034a284bde54dec059ec96bb9ac49d6d561f24141f"},{"sourcePath":"la-noire/los-angeles/default-v2","assetPath":"la-noire__los-angeles","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["png","png","png","png","png","png","png","png"],"bounds":{"x":0,"y":2560,"width":8192,"height":5632},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"Zg8="},"3":{"x":0,"y":2,"w":8,"h":6,"bits":"DDw8OAD/"},"4":{"x":0,"y":5,"w":16,"h":11,"bits":"8ADwA/AP4A/AD4APAAAAAAAAAAD//w=="},"5":{"x":0,"y":10,"w":32,"h":22,"bits":"AH8AAAD/AAAA/wEAAP8PAAD+PwAA/P8AAPz/AADw/wAA8P8AAPD/AACA/wAAgP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////w=="},"6":{"x":16,"y":20,"w":32,"h":24,"bits":"+AEAAP8DAAD/PwAA//8AAP//AAD+/wMA/v8HAP7/DwD8/w8A+P//B+D//w/g//8f4P//f+D//38A//9/AP//fwD+/38A/P9/APD/fwCA/38AgP9/AID//wAA//8AAP5/"},"7":{"x":38,"y":43,"w":25,"h":15,"bits":"4D8A/P8A/P8D+P8P8N8/AID/AQD8AwD4DwDAPwDA/wDg/wHA/w+A/x8A/z8AAH4="}},"stamp":"a655baa1cb059f7aeef505b9f37e2ede78e9d86888fd500f3f6296f14cbb8999"},{"sourcePath":"marathon/cryo-archive/default-v2","assetPath":"marathon__cryo-archive","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":2304,"width":5120,"height":3584},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"////"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"//7///////9//w=="},"5":{"x":6,"y":9,"w":20,"h":14,"bits":"5//8/7////////////////////////////////////85/+8="}},"stamp":"9067936486c61a0a2abe11b6f41ad14b50b34ac067619931f04bd025301d26b5"},{"sourcePath":"marathon/dire-marsh/default-v2","assetPath":"marathon__dire-marsh","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":2048,"width":4608,"height":3840},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"3v9/"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"4PH3//////ePAw=="},"5":{"x":7,"y":8,"w":18,"h":15,"bits":"AHgA+AP+H/x//v/9////////////////3/8//3/4f8ADAA=="}},"stamp":"7678d7de507cd5ba24ddb84161786bff15f88a9a3658ff03500404476f19d801"},{"sourcePath":"marathon/outpost/default-v1","assetPath":"marathon__outpost","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":2304,"y":2304,"width":3584,"height":3584},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":1,"y":1,"w":2,"h":2,"bits":"Dw=="},"3":{"x":2,"y":2,"w":4,"h":4,"bits":"//8="},"4":{"x":4,"y":4,"w":8,"h":8,"bits":"HH7+////fz4="},"5":{"x":9,"y":9,"w":14,"h":14,"bits":"+AH/4f/4f/6///////////////9//g//AQ=="}},"stamp":"0600cc01453f477d8a952d9af13a8731107cad676f57403e57b3490ab22a6484"},{"sourcePath":"marathon/perimeter/default-v5","assetPath":"marathon__perimeter","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":1536,"width":4608,"height":4864},"interpolate":true,"background":"#0f1112","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"9m8="},"3":{"x":1,"y":1,"w":6,"h":6,"bits":"3v//ngM="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"/Pj///////+/f/54AQ=="},"5":{"x":7,"y":6,"w":18,"h":19,"bits":"8B/A/8D/D/////////////////////f////////7/8//H/8//D/w/8CPAQ=="}},"stamp":"15258e16bebe28bf2b74127910f106331201ed7df3d8a2e34cca71461f13ed5e"},{"sourcePath":"old-school-runescape/runescape-surface/default-v6","assetPath":"old-school-runescape__runescape-surface","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1024,"y":2304,"width":6144,"height":4352},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"/w8="},"3":{"x":1,"y":2,"w":6,"h":5,"bits":"////Pw=="},"4":{"x":2,"y":4,"w":12,"h":9,"bits":"/////////////////w8="},"5":{"x":4,"y":9,"w":24,"h":17,"bits":"////////////////////////////////////////////////////////////////////"},"6":{"x":8,"y":18,"w":47,"h":33,"bits":"/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////38="},"7":{"x":16,"y":36,"w":94,"h":66,"bits":"/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////w8="}},"stamp":"58ddc94188893b319a80e58c59da45aa9a495ef93bc137d08662d3d160c7470a"},{"sourcePath":"palworld/1-0/default-v1","assetPath":"palworld__palpagos-islands","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":5632,"height":6656},"interpolate":true,"background":"#0d151e","coverage":{"2":{"x":0,"y":0,"w":3,"h":4,"bits":"/w8="},"3":{"x":0,"y":0,"w":6,"h":7,"bits":"3/f/v+8B"},"4":{"x":0,"y":0,"w":11,"h":13,"bits":"//nPf/7zn//73/z3xz/+8Ycf"},"5":{"x":0,"y":0,"w":22,"h":26,"bits":"//8BD2Dw/xj8fwb/v8H/f+D/H/j/B/7/gf9/4P8P8P9//Pk/P/7Pg/9z8P8f/j+A/w/w/wP+f4D/H+D/A/h/AP4fgP8D4D8A"}},"stamp":"996be385b3154db051054b12ed8a1d8649f519719cdcd0f9d2438a403c0fefee"},{"sourcePath":"pokemon-firered-leafgreen/kanto/firered-v2","assetPath":"pokemon-firered-leafgreen__kanto__firered","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false,"stamp":"d4bb208207242a590371bf33e43ebaaff2924ab0a5b499b9af720d5d8c7a7831"},{"sourcePath":"pokemon-firered-leafgreen/kanto/leafgreen-v3","assetPath":"pokemon-firered-leafgreen__kanto__leafgreen","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false,"stamp":"5cbdce0949a434cf84744d3526485be0a8765d5d94abcca1e12f6701428fc488"},{"sourcePath":"pokemon-yellow/kanto/yellow-v1","assetPath":"pokemon-red-blue-yellow__yellow","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","jpg"],"interpolate":false,"stamp":"64fe15fa66a4d3aa2d4cb3aaa9a03534c36d3c311a0ec6954d7ae1ba22d06b1b"},{"sourcePath":"skyrim/skyrim/default-v1","assetPath":"skyrim__skyrim","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":3840,"height":3072},"interpolate":true,"coverage":{"1":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"2":{"x":0,"y":0,"w":2,"h":2,"bits":"Dw=="},"3":{"x":0,"y":0,"w":4,"h":3,"bits":"/w8="},"4":{"x":0,"y":0,"w":8,"h":6,"bits":"////////"},"5":{"x":0,"y":0,"w":15,"h":12,"bits":"/////////////////////////////w8="}},"stamp":"eca8f60ebaf41e152f52e4a5b9027eca7229316f31f74afe1a4dd2718704a263"},{"sourcePath":"skyrim/solstheim/default-v1","assetPath":"skyrim__solstheim","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":0,"y":0,"width":1536,"height":1536},"interpolate":true,"coverage":{"1":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"2":{"x":0,"y":0,"w":1,"h":1,"bits":"AQ=="},"3":{"x":0,"y":0,"w":2,"h":2,"bits":"Dw=="},"4":{"x":0,"y":0,"w":3,"h":3,"bits":"/wE="},"5":{"x":0,"y":0,"w":6,"h":6,"bits":"/////w8="}},"stamp":"ba3eb3517560851f060eb9794dc24ccaf9f4cd5e41b9d8c6ddfdb2a4f657ab32"},{"sourcePath":"sonic-frontiers/ares-island/default-v1","assetPath":"sonic-frontiers__ares-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}},"stamp":"63367fa6f9c6ddcdfe32d0dcf08816e85031c25584cfdfd164ecec377aba0891"},{"sourcePath":"sonic-frontiers/chaos-island/default-v1","assetPath":"sonic-frontiers__chaos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}},"stamp":"41435c62bcc5fda8a058862ba93c63394b6c8436be2ce8742dcc1966a6fa9b61"},{"sourcePath":"sonic-frontiers/kronos-island/default-v1","assetPath":"sonic-frontiers__kronos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}},"stamp":"dd3f7fb5c527eaa13ecf400ee4eed3bbe379bf629949a700b052176beb3d88c2"},{"sourcePath":"sonic-frontiers/ouranos-island/default-v1","assetPath":"sonic-frontiers__ouranos-island","minZoom":0,"maxZoom":6,"fullZoom":5,"sourceZoom":14,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1536,"width":5120,"height":5120},"interpolate":true,"background":"#c6cecb","coverage":{"3":{"x":1,"y":1,"w":6,"h":6,"bits":"/////w8="},"4":{"x":3,"y":3,"w":10,"h":10,"bits":"////////////////Dw=="},"5":{"x":6,"y":6,"w":20,"h":20,"bits":"/v/3///////////////////////////////////////////////////////////v/38="},"6":{"x":12,"y":12,"w":40,"h":40,"bits":"wP///wP8////P/7///9//v///3/+////f/7///9////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+////f/7///9//v///3/+////f/z///8/wP///wM="}},"stamp":"9d75cda33af10a1ad7c5683328c7c2072140e26fb9542620e3935e25a2b5c3a3"},{"sourcePath":"temtem/airborne-archipelago/default-v11","assetPath":"temtem__airborne-archipelago","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["png","png","png","png","png","png"],"bounds":{"x":512,"y":2560,"width":6400,"height":5120},"interpolate":true,"background":"#002344","coverage":{"2":{"x":0,"y":1,"w":4,"h":3,"bits":"/w8="},"3":{"x":0,"y":2,"w":7,"h":6,"bits":"fr/ffzcA"},"4":{"x":1,"y":5,"w":13,"h":10,"bits":"zs7/+T//5/74nv//fB+AAQA="},"5":{"x":2,"y":10,"w":25,"h":20,"bits":"4MAA8MH54A3wg///h///Dz48Hz7/Pv7+Pfzge+A55wB2HADv+If//5//+z9/4L/+AAv/AQD6AQDAAwAABwAA"}},"stamp":"d1ca811e3905cc1c3aef3c1af384a62fd2e159e6abfafd0faee509aefc1f4ca2"},{"sourcePath":"tunic/world/default-v2","assetPath":"tunic__world","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"interpolate":true,"background":"#000000","coverage":{"3":{"x":0,"y":0,"w":8,"h":8,"bits":"/v//v/9///s="},"4":{"x":0,"y":0,"w":16,"h":16,"bits":"DD+84CJv/h/+ffjv88/4DfJ/+//jP64bzkt+/8/jQDw="},"5":{"x":0,"y":0,"w":32,"h":32,"bits":"AABgAHAABg/wBgBggMEAOAwMzgAAADAYwPGBAdz/fwDA/3Me/P/zCcD/f2DA23cMDv7/AAD//2DA//MAwP8jAAD+/zEM+J8HAP7fO475/8cO+OMHAPjnB2AAxwMYjAcAwODHABiABTD4H8wB4Ac+eP9wAHgAAAYAAADwDwAwAAA="},"6":{"x":26,"y":25,"w":8,"h":5,"bits":"//////8="},"7":{"x":54,"y":51,"w":10,"h":6,"bits":"Dvzz///+ww8="}},"stamp":"430611c8860cf71cdf328b354d24bb264de6554ca5068123c62a4fd18206c471"},{"sourcePath":"zelda-breath-of-the-wild/hyrule/default-v1","assetPath":"zelda-breath-of-the-wild__hyrule","minZoom":0,"maxZoom":5,"fullZoom":5,"sourceZoom":13,"formats":["jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1792,"y":2048,"width":4608,"height":4096},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":1,"w":4,"h":2,"bits":"/w=="},"3":{"x":1,"y":2,"w":6,"h":4,"bits":"////"},"4":{"x":3,"y":4,"w":10,"h":8,"bits":"/////////////w=="},"5":{"x":7,"y":8,"w":18,"h":16,"bits":"////////////////////////////////////////////////"}},"stamp":"bc0a71cdc8b36ef576da2c1986275dccd87ee0ccdbddfa4dd82f29c2c2cab36f"},{"sourcePath":"zelda-tears-of-the-kingdom/hyrule/default-v2","assetPath":"zelda-tears-of-the-kingdom__hyrule","minZoom":0,"maxZoom":7,"fullZoom":5,"sourceZoom":15,"formats":["jpg","jpg","jpg","jpg","jpg","jpg","jpg","jpg"],"bounds":{"x":1536,"y":1024,"width":4864,"height":5888},"interpolate":true,"background":"#000000","coverage":{"2":{"x":0,"y":0,"w":4,"h":4,"bits":"Zm8="},"3":{"x":1,"y":1,"w":6,"h":6,"bits":"nud9PAM="},"4":{"x":3,"y":2,"w":10,"h":12,"bits":"/vjHDz/88ffPP/jjhwce"},"5":{"x":6,"y":4,"w":19,"h":23,"bits":"4H/A/wP+f8D/A/4P8D+A/wH8D+B/AP8f+P/A/wD+h/8//P8B/A/AvwP+P/D/gH8A/APgHwD/AA=="},"6":{"x":13,"y":8,"w":36,"h":45,"bits":"AAAeAADA//8A4P//DwD+//8A4P//DwDA//8PAPz//wDA//8PAPz/fwDA//8AAPz/DwDA//8AAPz/DwDA//8AAPz/DwDA/38AAPz/BwDA//8AAPz/7wHA//8/APz//wfA//9/APz/DwDA//8AAPz/DwDA//8A/Pz/D/D///8A////D/D///8AAPz/DwDA/38AAAAfAAAA/388APD/8w8A////APD/8wYA/z8AAPj/AwCA/z8AAPj/AwCA/z8AAPj/AwCA/z8AAPj/AwA="},"7":{"x":26,"y":17,"w":72,"h":89,"bits":"AAAAAPwDAAAAAADg////PwAAAADg////fwAAAPj/////fwAAAPj/////fwAAAJD9////fwAAAPj/////fwAAAPj/////fwAAAPj/////fwAAAADw////fwAAAADw/////38AAADw/////38AAADw////f38AAADw////f38AAADw////f38AAADw////fzYAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADg////PwAAAADA////HwAAAAAAAPwBAAAAAADg////PwAAAADg////fwAAAADw////fwAAAADw////fwAAAADw////f/gDAADw////f/wDAADw////f/4LAADw//////sdAADw//////8fAADw//////sfAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAAAADw////fwAA4H/w////fwAA4P/w////fwAAWm/9////fwAA////////fwAA////////fwAA////////fwAAAADw////fwAAAADw////fwAAAADg////fwAAAADg////PwAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAP4DAAAAAAAA////GeADAAAA////D/AHAAAA////D/AHAAAA////D9tmAAAA////n///AAAA////n/9/AAAA/v//D9kkAAAA/v//DwAAAAAA/v//DwAAAAAA/v//DwAAAAAA/v//DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////DwAAAACA////AQAAAACA////AQAAAACA////CQAAAACA////DwAAAACA/9Y9AAAA"}},"stamp":"1caca2d7148b85ac0ef30da6f1251822620e8739ccb4ae81e1ecbe46e628f5b6"}]}@@ -131,6 +131,10 @@ type variantManifest struct {
131131 Interpolate bool `json:"interpolate"`132132 Background string `json:"background,omitempty"`133133 Coverage map[string]*levelCoverage `json:"coverage,omitempty"`134+ // Stamp names everything this pyramid was derived from -- the captured135+ // tiles and the tool that reduced them -- so a later run can tell that136+ // nothing has moved and keep what it already built.137+ Stamp string `json:"stamp,omitempty"`134138 }135139 136140 // levelCoverage is a row-major bitset over the [X,Y,W,H] tile window of one@@ -194,19 +198,20 @@ type contentBounds struct {
194198 func main() {195199 source := flag.String("source", "", "FMG archive root")196200 output := flag.String("output", "", "embedded tile destination")201+ force := flag.Bool("force", false, "derive every pyramid again, even one nothing has changed under")197202 flag.Parse()198203 199204 if *source == "" || *output == "" {200205 fmt.Fprintln(os.Stderr, "tiles: -source and -output are required")201206 os.Exit(2)202207 }203- if err := run(*source, *output); err != nil {208+ if err := run(*source, *output, *force); err != nil {204209 fmt.Fprintln(os.Stderr, "tiles:", err)205210 os.Exit(1)206211 }207212 }208213 209-func run(source, output string) error {214+func run(source, output string, force bool) error {210215 var index archive211216 if err := readJSON(filepath.Join(source, "archive.json"), &index); err != nil {212217 return err@@ -222,6 +227,12 @@ func run(source, output string) error {
222227 }223228 defer os.RemoveAll(temp)224229 230+ // What the last run left, so a layer nothing has changed under can be kept231+ // where it is rather than derived again.232+ built := manifestBySource(readManifest(output))233+ var carried int234+ derived := make(map[string]bool)235+225236 out := manifest{TileSize: tileSize, Size: worldSize}226237 for _, game := range index.Games {227238 mapDirs, err := filepath.Glob(filepath.Join(source, game.Directory, "maps", "*"))@@ -246,25 +257,99 @@ func run(source, output string) error {
246257 continue247258 }248259 for _, plan := range plans {260+ stamp := planStamp(plan)261+ if kept, ok := carry(built, plan, stamp, output, force); ok {262+ out.Variants = append(out.Variants, kept)263+ carried++264+ continue265+ }249266 fmt.Printf("tile %s / %s / %s\n", game.Title, title, plan.SourcePath)250267 entry, err := buildPyramid(temp, plan)251268 if err != nil {252269 return fmt.Errorf("%s / %s: %w", title, plan.SourcePath, err)253270 }271+ entry.Stamp = stamp254272 out.Variants = append(out.Variants, entry)273+ derived[plan.AssetPath] = true255274 }256275 }257276 }258277 sort.Slice(out.Variants, func(i, j int) bool {259278 return out.Variants[i].SourcePath < out.Variants[j].SourcePath260279 })261- if err := writeJSON(filepath.Join(temp, "index.json"), out); err != nil {280+ if err := installPyramids(temp, output, out, derived); err != nil {281+ return err282+ }283+ fmt.Printf("%d layers derived · %d carried over\n", len(derived), carried)284+ return nil285+}286+287+// carry keeps a pyramid the last run left, when nothing it was derived from has288+// moved. Keeping one is doing nothing at all: it stays where it is, and only289+// the layers that were derived again are installed over the top.290+func carry(291+ built map[string]variantManifest,292+ plan tilePlan,293+ stamp, output string,294+ force bool,295+) (variantManifest, bool) {296+ if force {297+ return variantManifest{}, false298+ }299+ entry, ok := built[plan.SourcePath]300+ if !ok || entry.Stamp == "" || entry.Stamp != stamp || entry.AssetPath != plan.AssetPath {301+ return variantManifest{}, false302+ }303+ if _, err := os.Stat(filepath.Join(output, plan.AssetPath)); err != nil {304+ // Whatever the last run left is not there to be kept, so derive it.305+ return variantManifest{}, false306+ }307+ return entry, true308+}309+310+// installPyramids moves the layers that were derived into the output, takes out311+// any layer the archive no longer offers, and writes the index last.312+//313+// A layer arrives by one rename, so a reader of the output never sees a314+// half-written pyramid. The index naming the old stamps until the end is what315+// makes a run that dies partway safe: the next run finds a stamp that does not316+// match what is on disk and derives that layer again.317+func installPyramids(temp, output string, out manifest, derived map[string]bool) error {318+ if err := os.MkdirAll(output, 0o755); err != nil {319+ return fmt.Errorf("create tile output: %w", err)320+ }321+ for asset := range derived {322+ destination := filepath.Join(output, asset)323+ if err := os.RemoveAll(destination); err != nil {324+ return fmt.Errorf("replace %s: %w", asset, err)325+ }326+ if err := os.Rename(filepath.Join(temp, asset), destination); err != nil {327+ return fmt.Errorf("install %s: %w", asset, err)328+ }329+ }330+331+ wanted := make(map[string]bool, len(out.Variants))332+ for _, variant := range out.Variants {333+ wanted[variant.AssetPath] = true334+ }335+ entries, err := os.ReadDir(output)336+ if err != nil {262337 return err263338 }264- if err := replaceDirectory(temp, output); err != nil {339+ for _, entry := range entries {340+ if !entry.IsDir() || wanted[entry.Name()] {341+ continue342+ }343+ if err := os.RemoveAll(filepath.Join(output, entry.Name())); err != nil {344+ return fmt.Errorf("remove %s: %w", entry.Name(), err)345+ }346+ }347+348+ staged := filepath.Join(temp, "index.json")349+ if err := writeJSON(staged, out); err != nil {265350 return err266351 }267- return nil352+ return os.Rename(staged, filepath.Join(output, "index.json"))268353 }269354 270355 func inspectMap(mapDir string) ([]tilePlan, string, string, error) {@@ -881,28 +966,6 @@ func encodeImage(path string, value image.Image, format string) error {
881966 return file.Close()882967 }883968 884-func replaceDirectory(temp, output string) error {885- backup := output + ".previous"886- if err := os.RemoveAll(backup); err != nil {887- return err888- }889- if _, err := os.Stat(output); err == nil {890- if err := os.Rename(output, backup); err != nil {891- return fmt.Errorf("backup old tile output: %w", err)892- }893- } else if !errors.Is(err, os.ErrNotExist) {894- return err895- }896- if err := os.Rename(temp, output); err != nil {897- _ = os.Rename(backup, output)898- return fmt.Errorf("install tile output: %w", err)899- }900- if err := os.RemoveAll(backup); err != nil {901- return fmt.Errorf("remove old tile output: %w", err)902- }903- return nil904-}905-906969 func writeJSON(path string, value any) error {907970 data, err := json.Marshal(value)908971 if err != nil {@@ -0,0 +1,135 @@
1+package main2+3+import (4+ "crypto/sha256"5+ "embed"6+ "encoding/hex"7+ "fmt"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "sort"12+ "sync"13+)14+15+// Every pyramid was derived afresh on every run: adding one game re-decoded and16+// re-reduced the other seventeen, half a minute of work to arrive at the bytes17+// that were already there. A layer is derived from tiles that are stored under18+// their own content hash, so what went into a pyramid can be written down and19+// compared, and a layer whose capture has not moved can be carried over from20+// the last run instead.21+//22+// The tool's own source counts as an input. Changing how a level is reduced or23+// where the content bounds land has to invalidate every pyramid, and a stamp24+// that only watched the archive would quietly keep serving the old derivation.25+//26+//go:embed main.go stamp.go27+var toolSource embed.FS28+29+var toolStamp = sync.OnceValue(func() string {30+ sum := sha256.New()31+ entries, err := fs.ReadDir(toolSource, ".")32+ if err != nil {33+ // The files are embedded at build time, so this cannot fail in a built34+ // binary. Falling back to a random-looking constant would defeat the35+ // point of the stamp, and rebuilding everything is the safe answer.36+ return "unstamped"37+ }38+ names := make([]string, 0, len(entries))39+ for _, entry := range entries {40+ names = append(names, entry.Name())41+ }42+ sort.Strings(names)43+ for _, name := range names {44+ data, err := toolSource.ReadFile(name)45+ if err != nil {46+ return "unstamped"47+ }48+ fmt.Fprintf(sum, "%s\x00%d\x00", name, len(data))49+ sum.Write(data)50+ }51+ return hex.EncodeToString(sum.Sum(nil))52+})53+54+// planStamp names everything a pyramid is derived from: the tool that derives55+// it, the shape the plan settled on, and the content hash of every source tile56+// that goes into it. Two runs that would write the same bytes stamp the same.57+func planStamp(plan tilePlan) string {58+ sum := sha256.New()59+ fmt.Fprintf(sum, "tool\x00%s\x00", toolStamp())60+ fmt.Fprintf(sum, "layer\x00%s\x00%s\x00", plan.SourcePath, plan.AssetPath)61+ fmt.Fprintf(sum, "zooms\x00%d\x00%d\x00", plan.MaxFullZoom, plan.MaxSourceZoom)62+ fmt.Fprintf(sum, "format\x00%s\x00%t\x00", plan.PreferredFormat, plan.Interpolate)63+ if plan.Bounds != nil {64+ fmt.Fprintf(sum, "bounds\x00%d\x00%d\x00%d\x00%d\x00",65+ plan.Bounds.X, plan.Bounds.Y, plan.Bounds.Width, plan.Bounds.Height)66+ }67+68+ zooms := make([]int, 0, len(plan.Levels))69+ for zoom := range plan.Levels {70+ zooms = append(zooms, zoom)71+ }72+ sort.Ints(zooms)73+ for _, zoom := range zooms {74+ files := append([]tileFile(nil), plan.Levels[zoom]...)75+ sort.Slice(files, func(i, j int) bool {76+ if files[i].Record.X != files[j].Record.X {77+ return files[i].Record.X < files[j].Record.X78+ }79+ return files[i].Record.Y < files[j].Record.Y80+ })81+ fmt.Fprintf(sum, "level\x00%d\x00%d\x00", zoom, len(files))82+ for _, file := range files {83+ fmt.Fprintf(sum, "%d\x00%d\x00%s\x00%s\x00",84+ file.Record.X, file.Record.Y, file.Record.ContentHash, file.Format)85+ }86+ }87+ return hex.EncodeToString(sum.Sum(nil))88+}89+90+// linkTree reproduces a directory as links to the files it already holds, and91+// reports how many it brought across. Directories are visited before their92+// contents, so each one is made once rather than checked for again per file.93+func linkTree(from, to string) (int, error) {94+ tiles := 095+ err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {96+ if err != nil {97+ return err98+ }99+ relative, err := filepath.Rel(from, path)100+ if err != nil {101+ return err102+ }103+ destination := filepath.Join(to, relative)104+ if entry.IsDir() {105+ return os.Mkdir(destination, 0o755)106+ }107+ tiles++108+ if err := os.Link(path, destination); err == nil {109+ return nil110+ }111+ // A link can fail across filesystems, and on one that has none. The112+ // bytes are what matter, so fall back to writing them out.113+ return copyFile(path, destination)114+ })115+ return tiles, err116+}117+118+// readManifest is the index left by the last run, or an empty one where there119+// was no last run. A manifest that cannot be read is not an error: it means120+// everything is derived again, which is what a first run does anyway.121+func readManifest(output string) manifest {122+ var previous manifest123+ if err := readJSON(filepath.Join(output, "index.json"), &previous); err != nil {124+ return manifest{}125+ }126+ return previous127+}128+129+func manifestBySource(previous manifest) map[string]variantManifest {130+ bySource := make(map[string]variantManifest, len(previous.Variants))131+ for _, variant := range previous.Variants {132+ bySource[variant.SourcePath] = variant133+ }134+ return bySource135+}@@ -0,0 +1,157 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+func samplePlan() tilePlan {10+ return tilePlan{11+ AssetPath: "tunic__world",12+ SourcePath: "tunic/world/default-v2",13+ MaxFullZoom: 12,14+ MaxSourceZoom: 13,15+ Interpolate: true,16+ PreferredFormat: "jpg",17+ Bounds: &contentBounds{X: 16, Y: 32, Width: 512, Height: 512},18+ Levels: map[int][]tileFile{19+ 12: {20+ {Record: tileRecord{X: 1, Y: 1, ContentHash: "aaa"}, Format: "jpg"},21+ {Record: tileRecord{X: 1, Y: 2, ContentHash: "bbb"}, Format: "jpg"},22+ },23+ 13: {24+ {Record: tileRecord{X: 2, Y: 2, ContentHash: "ccc"}, Format: "jpg"},25+ },26+ },27+ }28+}29+30+// The stamp decides whether half a minute of derivation is skipped, so it has31+// to say the same thing about the same capture and something different about32+// every part of it that changes what would be written.33+func TestPlanStampAnswersForEveryInput(t *testing.T) {34+ base := planStamp(samplePlan())35+ if base == planStamp(tilePlan{}) {36+ t.Fatal("an empty plan stamps the same as a captured one")37+ }38+39+ // The order tiles happen to be listed in is not an input.40+ shuffled := samplePlan()41+ shuffled.Levels[12] = []tileFile{shuffled.Levels[12][1], shuffled.Levels[12][0]}42+ if got := planStamp(shuffled); got != base {43+ t.Error("the same tiles in another order stamp differently")44+ }45+46+ changes := map[string]func(*tilePlan){47+ "a tile recaptured": func(plan *tilePlan) {48+ plan.Levels[12][0].Record.ContentHash = "zzz"49+ },50+ "a tile added": func(plan *tilePlan) {51+ plan.Levels[12] = append(plan.Levels[12],52+ tileFile{Record: tileRecord{X: 9, Y: 9, ContentHash: "ddd"}, Format: "jpg"})53+ },54+ "a level captured deeper": func(plan *tilePlan) { plan.MaxSourceZoom = 14 },55+ "the complete level moved": func(plan *tilePlan) { plan.MaxFullZoom = 11 },56+ "the content bounds moved": func(plan *tilePlan) { plan.Bounds.Width = 1024 },57+ "the reduction changed": func(plan *tilePlan) { plan.Interpolate = false },58+ "the format changed": func(plan *tilePlan) { plan.PreferredFormat = "png" },59+ "the layer renamed": func(plan *tilePlan) { plan.AssetPath = "tunic__other" },60+ }61+ for name, change := range changes {62+ t.Run(name, func(t *testing.T) {63+ plan := samplePlan()64+ change(&plan)65+ if planStamp(plan) == base {66+ t.Errorf("%s stamps the same as before it", name)67+ }68+ })69+ }70+}71+72+// The tool derives the pyramid, so a change to the tool has to invalidate one.73+func TestToolStampIsRecorded(t *testing.T) {74+ stamp := toolStamp()75+ if stamp == "" || stamp == "unstamped" {76+ t.Fatalf("tool stamp = %q, so nothing would notice the tool changing", stamp)77+ }78+ if planStamp(samplePlan()) == "" {79+ t.Fatal("a plan stamped as nothing")80+ }81+}82+83+func TestLinkTreeBringsEveryFileAcross(t *testing.T) {84+ from, to := filepath.Join(t.TempDir(), "from"), filepath.Join(t.TempDir(), "to")85+ for _, name := range []string{"3/0/0.jpg", "3/0/1.jpg", "4/1/0.png"} {86+ path := filepath.Join(from, name)87+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {88+ t.Fatal(err)89+ }90+ if err := os.WriteFile(path, []byte(name), 0o644); err != nil {91+ t.Fatal(err)92+ }93+ }94+95+ tiles, err := linkTree(from, to)96+ if err != nil {97+ t.Fatal(err)98+ }99+ if tiles != 3 {100+ t.Errorf("linked %d tiles, want 3", tiles)101+ }102+ for _, name := range []string{"3/0/0.jpg", "3/0/1.jpg", "4/1/0.png"} {103+ data, err := os.ReadFile(filepath.Join(to, name))104+ if err != nil {105+ t.Fatal(err)106+ }107+ if string(data) != name {108+ t.Errorf("%s = %q, want %q", name, data, name)109+ }110+ }111+}112+113+// A run installs what it derived and takes out what the archive no longer114+// offers, leaving everything it carried where it already was.115+func TestInstallPyramidsKeepsCarriedAndPrunesRetired(t *testing.T) {116+ temp, output := t.TempDir(), t.TempDir()117+ write := func(root, name string, body string) {118+ path := filepath.Join(root, name)119+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {120+ t.Fatal(err)121+ }122+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {123+ t.Fatal(err)124+ }125+ }126+ write(output, "carried/3/0/0.jpg", "kept")127+ write(output, "derived/3/0/0.jpg", "old")128+ write(output, "retired/3/0/0.jpg", "gone")129+ write(temp, "derived/3/0/0.jpg", "new")130+131+ out := manifest{Variants: []variantManifest{132+ {AssetPath: "carried", SourcePath: "a"},133+ {AssetPath: "derived", SourcePath: "b"},134+ }}135+ if err := installPyramids(temp, output, out, map[string]bool{"derived": true}); err != nil {136+ t.Fatal(err)137+ }138+139+ for name, want := range map[string]string{140+ "carried/3/0/0.jpg": "kept",141+ "derived/3/0/0.jpg": "new",142+ } {143+ data, err := os.ReadFile(filepath.Join(output, name))144+ if err != nil {145+ t.Fatal(err)146+ }147+ if string(data) != want {148+ t.Errorf("%s = %q, want %q", name, data, want)149+ }150+ }151+ if _, err := os.Stat(filepath.Join(output, "retired")); !os.IsNotExist(err) {152+ t.Error("a layer the archive no longer offers was left behind")153+ }154+ if _, err := os.Stat(filepath.Join(output, "index.json")); err != nil {155+ t.Error("the index was not written")156+ }157+}