makepad/libs/map_build/src/repack.rs
Admin 30fc13bba0 map-tiles: repack runs on all cores, resumes per shard, reports --status
Mac-side only: no in-place rewrite, no push, no fallback source. Shards are
written atomically and skipped on resume; root.mkidx lands once at the end.
Ranged --verify streams the output back. 16 jobs: 4.2 tiles/s on the
500-tile sample (REPACK2b lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00

2554 lines
93 KiB
Rust

//! Lossless-in-the-renderer-sense rewrite of decoded Makepad vector tiles.
use crate::mkmap::{
content_hash, encode_leaf_directory, write_root_index, BlobRef, LeafEntry, RootIndex,
RootRecord, SHARD_HARD_CAP,
};
use makepad_mbtile_reader::{
compress_tile, read_pb_len_slice, read_pb_varint, skip_pb_field, MkmapReader, MkmapTileRef,
TileCodec, TileCompression, DETAIL_POINT_EXTRA_KEYS, DETAIL_WAY_KEYS,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{mpsc::sync_channel, Arc, Mutex};
use std::time::{Duration, Instant};
pub const DETAIL_LAYERS: &[&str] = &[
"osm_points",
"osm_lines",
"osm_polygons",
"osm_relation_lines",
"osm_relation_polygons",
"osm_relation_points",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PolicyAction {
Keep,
Drop,
}
#[derive(Clone, Copy, Debug)]
pub struct PolicyRow {
pub action: PolicyAction,
pub item: &'static str,
pub reader_or_reason: &'static str,
}
pub const POLICY_BASE: usize = 0;
pub const POLICY_DETAIL: usize = 1;
pub const POLICY_REGIONS: usize = 2;
pub const POLICY_BUILDINGS: usize = 3;
pub const POLICY_FILLS: usize = 4;
pub const POLICY_TAGS: usize = 5;
pub const POLICY_SYNTHETIC: usize = 6;
pub const POLICY_SHADOWS: usize = 7;
/// The archive data contract. Keep this table in lock-step with renderer
/// reads; reports use the same rows, so every removed byte has a stated case.
pub const DATA_POLICY: [PolicyRow; 8] = [
PolicyRow {
action: PolicyAction::Keep,
item: "shortbread base layers (non-osm_*)",
reader_or_reason: "widgets/src/map/tile.rs: LayerParseFilter::BaseNoDetailLayers",
},
PolicyRow {
action: PolicyAction::Keep,
item: "six osm_* detail layers: geometry + whitelisted tags",
reader_or_reason: "widgets/src/map/tile.rs: LayerParseFilter::DetailLayers/tag_key_whitelist",
},
PolicyRow {
action: PolicyAction::Keep,
item: "field 101 painter-cascade REGIONS",
reader_or_reason: "widgets/src/map/tile.rs: parse_baked_faces/bake.regions cascade hit",
},
PolicyRow {
action: PolicyAction::Keep,
item: "field 101 v4 building groups",
reader_or_reason: "widgets/src/map/tile.rs: bake.building_signature/bake.buildings substitution",
},
PolicyRow {
action: PolicyAction::Keep,
item: "field 100 baked fill triangulations",
reader_or_reason: "widgets/src/map/tile.rs: parse_baked_fills",
},
PolicyRow {
action: PolicyAction::Drop,
item: "osm_* tags outside DETAIL_WAY_KEYS + DETAIL_POINT_EXTRA_KEYS",
reader_or_reason: "discarded by widgets/src/map/tile.rs: tag_key_whitelist",
},
PolicyRow {
action: PolicyAction::Drop,
item: "__makepad_osm_id/type/closed",
reader_or_reason: "renderer-tree matches are cfg(test) probes; production only writes them",
},
PolicyRow {
action: PolicyAction::Drop,
item: "field 101 shadow shapes + grounded footprints",
reader_or_reason: "widgets/src/map/view.rs: draw_shadow_mask_pass derives live shadows",
},
];
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TileRewriteStats {
pub decoded_before: u64,
pub decoded_after: u64,
pub savings: [u64; DATA_POLICY.len()],
}
impl TileRewriteStats {
pub fn add_assign(&mut self, other: &Self) {
self.decoded_before += other.decoded_before;
self.decoded_after += other.decoded_after;
for (left, right) in self.savings.iter_mut().zip(other.savings) {
*left += right;
}
}
}
#[derive(Clone, Copy)]
struct PbField {
start: usize,
end: usize,
field: u32,
wire: u8,
payload_start: usize,
payload_end: usize,
}
fn next_field(bytes: &[u8], pos: &mut usize) -> Result<PbField, String> {
let start = *pos;
let key = read_pb_varint(bytes, pos)?;
let field = u32::try_from(key >> 3).map_err(|_| "protobuf field number overflow".to_string())?;
let wire = (key & 7) as u8;
if field == 0 {
return Err("protobuf field zero".to_string());
}
let payload_start;
let payload_end;
if wire == 2 {
let payload = read_pb_len_slice(bytes, pos)?;
payload_start = payload.as_ptr() as usize - bytes.as_ptr() as usize;
payload_end = payload_start + payload.len();
} else {
payload_start = *pos;
skip_pb_field(bytes, pos, wire)?;
payload_end = *pos;
}
Ok(PbField {
start,
end: *pos,
field,
wire,
payload_start,
payload_end,
})
}
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
while value >= 0x80 {
out.push((value as u8 & 0x7f) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
fn write_len_field(field: u32, payload: &[u8], out: &mut Vec<u8>) {
write_varint(u64::from(field) << 3 | 2, out);
write_varint(payload.len() as u64, out);
out.extend_from_slice(payload);
}
fn is_detail_layer(name: &str) -> bool {
DETAIL_LAYERS.contains(&name)
}
fn is_synthetic_key(key: &str) -> bool {
matches!(
key,
"__makepad_osm_id" | "__makepad_osm_type" | "__makepad_osm_closed"
)
}
pub fn detail_key_allowed(key: &str) -> bool {
DETAIL_WAY_KEYS.contains(&key) || DETAIL_POINT_EXTRA_KEYS.contains(&key)
}
fn layer_name(layer: &[u8]) -> Result<&str, String> {
let mut pos = 0;
while pos < layer.len() {
let field = next_field(layer, &mut pos)?;
if field.field == 1 && field.wire == 2 {
return std::str::from_utf8(&layer[field.payload_start..field.payload_end])
.map_err(|_| "MVT layer name is not UTF-8".to_string());
}
}
Err("MVT layer has no name".to_string())
}
fn feature_tags(feature: &[u8]) -> Result<Vec<(usize, usize)>, String> {
let mut tags = Vec::new();
let mut pos = 0;
while pos < feature.len() {
let field = next_field(feature, &mut pos)?;
if field.field == 2 {
if field.wire != 2 {
return Err("MVT feature tags are not packed".to_string());
}
let mut packed_pos = field.payload_start;
while packed_pos < field.payload_end {
let key = usize::try_from(read_pb_varint(feature, &mut packed_pos)?)
.map_err(|_| "MVT key index overflow".to_string())?;
let value = usize::try_from(read_pb_varint(feature, &mut packed_pos)?)
.map_err(|_| "MVT value index overflow".to_string())?;
tags.push((key, value));
}
if packed_pos != field.payload_end {
return Err("MVT packed tags overrun".to_string());
}
}
}
Ok(tags)
}
struct LayerTables<'a> {
fields: Vec<PbField>,
keys: Vec<&'a str>,
values: Vec<&'a [u8]>,
features: Vec<&'a [u8]>,
}
fn parse_layer_tables(layer: &[u8]) -> Result<LayerTables<'_>, String> {
let mut fields = Vec::new();
let mut keys = Vec::new();
let mut values = Vec::new();
let mut features = Vec::new();
let mut pos = 0;
while pos < layer.len() {
let field = next_field(layer, &mut pos)?;
match (field.field, field.wire) {
(2, 2) => features.push(&layer[field.payload_start..field.payload_end]),
(3, 2) => keys.push(
std::str::from_utf8(&layer[field.payload_start..field.payload_end])
.map_err(|_| "MVT key is not UTF-8".to_string())?,
),
(4, 2) => values.push(&layer[field.payload_start..field.payload_end]),
_ => {}
}
fields.push(field);
}
Ok(LayerTables {
fields,
keys,
values,
features,
})
}
fn rewrite_detail_layer_with(
layer: &[u8],
allowed: impl Fn(&str) -> bool,
) -> Result<Vec<u8>, String> {
let tables = parse_layer_tables(layer)?;
let mut kept_tags = Vec::with_capacity(tables.features.len());
let mut used_keys = vec![false; tables.keys.len()];
let mut used_values = vec![false; tables.values.len()];
for feature in &tables.features {
let mut tags = Vec::new();
for (key, value) in feature_tags(feature)? {
let key_name = tables
.keys
.get(key)
.ok_or_else(|| "MVT feature key index is out of range".to_string())?;
if value >= tables.values.len() {
return Err("MVT feature value index is out of range".to_string());
}
if allowed(key_name) {
used_keys[key] = true;
used_values[value] = true;
tags.push((key, value));
}
}
kept_tags.push(tags);
}
let mut key_map = vec![None; tables.keys.len()];
let mut retained_keys = Vec::new();
let mut key_by_name: HashMap<&str, u32> = HashMap::new();
for (old, key) in tables.keys.iter().copied().enumerate() {
if !used_keys[old] {
continue;
}
let mapped = if let Some(mapped) = key_by_name.get(key) {
*mapped
} else {
let mapped = retained_keys.len() as u32;
retained_keys.push(key);
key_by_name.insert(key, mapped);
mapped
};
key_map[old] = Some(mapped);
}
let mut value_map = vec![None; tables.values.len()];
let mut retained_values = Vec::new();
let mut value_by_payload: HashMap<&[u8], u32> = HashMap::new();
for (old, value) in tables.values.iter().copied().enumerate() {
if !used_values[old] {
continue;
}
let mapped = if let Some(mapped) = value_by_payload.get(value) {
*mapped
} else {
let mapped = retained_values.len() as u32;
retained_values.push(value);
value_by_payload.insert(value, mapped);
mapped
};
value_map[old] = Some(mapped);
}
let mut rewritten_features = Vec::with_capacity(tables.features.len());
for (feature, tags) in tables.features.iter().zip(kept_tags) {
let mut packed = Vec::new();
for (key, value) in tags {
write_varint(u64::from(key_map[key].unwrap()), &mut packed);
write_varint(u64::from(value_map[value].unwrap()), &mut packed);
}
let rewritten = rewrite_feature_filtered(feature, &packed)?;
rewritten_features.push(rewritten);
}
let mut out = Vec::with_capacity(layer.len());
let mut feature_index = 0;
let mut wrote_keys = false;
let mut wrote_values = false;
for field in tables.fields {
match (field.field, field.wire) {
(2, 2) => {
write_len_field(2, &rewritten_features[feature_index], &mut out);
feature_index += 1;
}
(3, 2) => {
if !wrote_keys {
for key in &retained_keys {
write_len_field(3, key.as_bytes(), &mut out);
}
wrote_keys = true;
}
}
(4, 2) => {
if !wrote_values {
for value in &retained_values {
write_len_field(4, value, &mut out);
}
wrote_values = true;
}
}
_ => out.extend_from_slice(&layer[field.start..field.end]),
}
}
Ok(out)
}
fn rewrite_feature_filtered(
feature: &[u8],
packed: &[u8],
) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(feature.len());
let mut pos = 0;
let mut wrote_tags = false;
while pos < feature.len() {
let field = next_field(feature, &mut pos)?;
if field.field == 2 {
if !wrote_tags && !packed.is_empty() {
write_len_field(2, packed, &mut out);
}
wrote_tags = true;
} else {
out.extend_from_slice(&feature[field.start..field.end]);
}
}
Ok(out)
}
fn fnv_step(hash: &mut u64, bytes: &[u8]) {
for byte in bytes {
*hash ^= u64::from(*byte);
*hash = hash.wrapping_mul(0x100_0000_01b3);
}
}
fn scan_shapes(
bytes: &[u8],
pos: &mut usize,
first: &mut u64,
mut second: Option<&mut u64>,
) -> Result<(), String> {
let shapes = usize::try_from(read_pb_varint(bytes, pos)?)
.map_err(|_| "field 101 shape count overflow".to_string())?;
if shapes > 1_000_000 {
return Err("field 101 shape count exceeds limit".to_string());
}
for _ in 0..shapes {
let rings = usize::try_from(read_pb_varint(bytes, pos)?)
.map_err(|_| "field 101 ring count overflow".to_string())?;
if rings > 1_000_000 {
return Err("field 101 ring count exceeds limit".to_string());
}
for _ in 0..rings {
let points = usize::try_from(read_pb_varint(bytes, pos)?)
.map_err(|_| "field 101 point count overflow".to_string())?;
if points > 4_000_000 {
return Err("field 101 point count exceeds limit".to_string());
}
let (mut x, mut y) = (0_i64, 0_i64);
for _ in 0..points {
x = x.wrapping_add(zigzag_decode(read_pb_varint(bytes, pos)?));
y = y.wrapping_add(zigzag_decode(read_pb_varint(bytes, pos)?));
fnv_step(first, &x.to_le_bytes());
fnv_step(first, &y.to_le_bytes());
if let Some(hash) = second.as_deref_mut() {
fnv_step(hash, &x.to_le_bytes());
fnv_step(hash, &y.to_le_bytes());
}
}
}
}
Ok(())
}
fn zigzag_decode(value: u64) -> i64 {
((value >> 1) as i64) ^ -((value & 1) as i64)
}
fn rewrite_baked_faces(blob: &[u8]) -> Result<Vec<u8>, String> {
let version = *blob.first().ok_or_else(|| "empty field 101".to_string())?;
if version != 3 && version != 4 {
return Err(format!("unsupported field 101 version {version}"));
}
let mut pos = 1;
let bucket_count = usize::try_from(read_pb_varint(blob, &mut pos)?)
.map_err(|_| "field 101 bucket count overflow".to_string())?;
if bucket_count > 1024 {
return Err("field 101 bucket count exceeds limit".to_string());
}
let prefix_end = pos;
let mut out = Vec::with_capacity(blob.len());
out.extend_from_slice(&blob[..prefix_end]);
for _ in 0..bucket_count {
let bucket_start = pos;
let _ = read_pb_varint(blob, &mut pos)?;
let signature_end = pos
.checked_add(8)
.filter(|end| *end <= blob.len())
.ok_or_else(|| "truncated field 101 signature".to_string())?;
pos = signature_end;
let checksum_pos = pos;
let checksum_end = pos
.checked_add(8)
.filter(|end| *end <= blob.len())
.ok_or_else(|| "truncated field 101 checksum".to_string())?;
let stored_checksum = u64::from_le_bytes(blob[pos..checksum_end].try_into().unwrap());
pos = checksum_end;
let body_len = usize::try_from(read_pb_varint(blob, &mut pos)?)
.map_err(|_| "field 101 body length overflow".to_string())?;
let body_end = pos
.checked_add(body_len)
.filter(|end| *end <= blob.len())
.ok_or_else(|| "truncated field 101 body".to_string())?;
let body = &blob[pos..body_end];
pos = body_end;
let mut bpos = 0;
let mut old_checksum = 0xcbf2_9ce4_8422_2325;
let mut new_checksum = old_checksum;
let regions = usize::try_from(read_pb_varint(body, &mut bpos)?)
.map_err(|_| "field 101 region count overflow".to_string())?;
if regions > 100_000 {
return Err("field 101 region count exceeds limit".to_string());
}
for _ in 0..regions {
let _ = read_pb_varint(body, &mut bpos)?;
for _ in 0..3 {
scan_shapes(
body,
&mut bpos,
&mut old_checksum,
Some(&mut new_checksum),
)?;
}
}
let regions_end = bpos;
bpos = bpos
.checked_add(8)
.filter(|end| *end <= body.len())
.ok_or_else(|| "truncated field 101 shadow signature".to_string())?;
scan_shapes(body, &mut bpos, &mut old_checksum, None)?;
scan_shapes(body, &mut bpos, &mut old_checksum, None)?;
let buildings_start = bpos;
if version == 4 {
bpos = bpos
.checked_add(8)
.filter(|end| *end <= body.len())
.ok_or_else(|| "truncated field 101 building signature".to_string())?;
let groups = usize::try_from(read_pb_varint(body, &mut bpos)?)
.map_err(|_| "field 101 building count overflow".to_string())?;
if groups > 100_000 {
return Err("field 101 building count exceeds limit".to_string());
}
for _ in 0..groups {
let _ = read_pb_varint(body, &mut bpos)?;
let _ = read_pb_varint(body, &mut bpos)?;
scan_shapes(
body,
&mut bpos,
&mut old_checksum,
Some(&mut new_checksum),
)?;
}
}
if bpos != body.len() {
return Err("field 101 body has trailing bytes".to_string());
}
if old_checksum != stored_checksum {
return Err(format!(
"field 101 checksum mismatch: stored {stored_checksum:016x}, got {old_checksum:016x}"
));
}
let mut new_body = Vec::with_capacity(body.len());
new_body.extend_from_slice(&body[..regions_end]);
new_body.extend_from_slice(&0_u64.to_le_bytes());
write_varint(0, &mut new_body);
write_varint(0, &mut new_body);
new_body.extend_from_slice(&body[buildings_start..]);
out.extend_from_slice(&blob[bucket_start..checksum_pos]);
out.extend_from_slice(&new_checksum.to_le_bytes());
write_varint(new_body.len() as u64, &mut out);
out.extend_from_slice(&new_body);
}
if pos != blob.len() {
return Err("field 101 has trailing bytes".to_string());
}
Ok(out)
}
fn varint_len(mut value: u64) -> usize {
let mut len = 1;
while value >= 0x80 {
len += 1;
value >>= 7;
}
len
}
fn len_field_size(field: u32, payload_len: usize) -> usize {
varint_len(u64::from(field) << 3 | 2) + varint_len(payload_len as u64) + payload_len
}
/// Rewrite one decoded tile protobuf according to [`DATA_POLICY`].
pub fn rewrite_tile(input: &[u8]) -> Result<(Vec<u8>, TileRewriteStats), String> {
let mut out = Vec::with_capacity(input.len());
let mut stats = TileRewriteStats {
decoded_before: input.len() as u64,
..Default::default()
};
let mut pos = 0;
while pos < input.len() {
let field = next_field(input, &mut pos)?;
match (field.field, field.wire) {
(3, 2) => {
let layer = &input[field.payload_start..field.payload_end];
if is_detail_layer(layer_name(layer)?) {
let final_layer = rewrite_detail_layer_with(layer, detail_key_allowed)?;
let no_synthetic =
rewrite_detail_layer_with(layer, |key| !is_synthetic_key(key))?;
let old_size = field.end - field.start;
let total = old_size.saturating_sub(len_field_size(3, final_layer.len())) as u64;
let synthetic = old_size
.saturating_sub(len_field_size(3, no_synthetic.len()))
as u64;
stats.savings[POLICY_SYNTHETIC] += synthetic.min(total);
stats.savings[POLICY_TAGS] += total.saturating_sub(synthetic);
write_len_field(3, &final_layer, &mut out);
} else {
out.extend_from_slice(&input[field.start..field.end]);
}
}
(101, 2) => {
let rewritten = rewrite_baked_faces(&input[field.payload_start..field.payload_end])?;
stats.savings[POLICY_SHADOWS] += (field.end - field.start)
.saturating_sub(len_field_size(101, rewritten.len())) as u64;
write_len_field(101, &rewritten, &mut out);
}
_ => out.extend_from_slice(&input[field.start..field.end]),
}
}
stats.decoded_after = out.len() as u64;
Ok((out, stats))
}
/// Strict verifier used by both unit tests and `makepad-map-repack --verify`.
pub fn verify_rewritten_tile(before: &[u8], after: &[u8]) -> Result<(), String> {
let expected = rewrite_tile(before)?.0;
if expected != after {
return Err("repacked tile differs from the deterministic policy rewrite".to_string());
}
let again = rewrite_tile(after)?.0;
if again != after {
return Err("repacked tile is not idempotent".to_string());
}
verify_kept_sections(before, after)
}
#[derive(Default)]
struct TileSections {
base_layers: Vec<(String, Vec<u8>)>,
detail_geometry: Vec<(String, Vec<Vec<u8>>)>,
detail_tags: Vec<(String, Vec<Vec<(String, Vec<u8>)>>)>,
fills: Vec<Vec<u8>>,
faces: Vec<(u64, Vec<u8>, Vec<u8>, bool)>,
}
fn feature_without_tags(feature: &[u8]) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let mut pos = 0;
while pos < feature.len() {
let field = next_field(feature, &mut pos)?;
if field.field != 2 {
out.extend_from_slice(&feature[field.start..field.end]);
}
}
Ok(out)
}
fn face_sections(blob: &[u8]) -> Result<Vec<(u64, Vec<u8>, Vec<u8>, bool)>, String> {
let version = *blob.first().ok_or_else(|| "empty field 101".to_string())?;
if version != 3 && version != 4 {
return Err(format!("unsupported field 101 version {version}"));
}
let mut pos = 1;
let count = usize::try_from(read_pb_varint(blob, &mut pos)?)
.map_err(|_| "field 101 count overflow".to_string())?;
let mut sections = Vec::with_capacity(count);
for _ in 0..count {
let bucket = read_pb_varint(blob, &mut pos)?;
pos = pos
.checked_add(16)
.filter(|end| *end <= blob.len())
.ok_or_else(|| "truncated field 101 bucket header".to_string())?;
let len = usize::try_from(read_pb_varint(blob, &mut pos)?)
.map_err(|_| "field 101 body overflow".to_string())?;
let body = blob
.get(pos..pos + len)
.ok_or_else(|| "truncated field 101 body".to_string())?;
pos += len;
let mut bpos = 0;
let regions = read_pb_varint(body, &mut bpos)? as usize;
let mut discard = 0xcbf2_9ce4_8422_2325;
for _ in 0..regions {
let _ = read_pb_varint(body, &mut bpos)?;
for _ in 0..3 {
scan_shapes(body, &mut bpos, &mut discard, None)?;
}
}
let region_bytes = body[..bpos].to_vec();
bpos += 8;
let shadow_shapes_start = bpos;
scan_shapes(body, &mut bpos, &mut discard, None)?;
let shapes_empty = body[shadow_shapes_start..bpos] == [0];
let footprint_start = bpos;
scan_shapes(body, &mut bpos, &mut discard, None)?;
let shadows_empty = shapes_empty && body[footprint_start..bpos] == [0];
let buildings = body[bpos..].to_vec();
sections.push((bucket, region_bytes, buildings, shadows_empty));
}
Ok(sections)
}
fn tile_sections(tile: &[u8]) -> Result<TileSections, String> {
let mut sections = TileSections::default();
let mut pos = 0;
while pos < tile.len() {
let field = next_field(tile, &mut pos)?;
match (field.field, field.wire) {
(3, 2) => {
let layer = &tile[field.payload_start..field.payload_end];
let name = layer_name(layer)?.to_string();
if is_detail_layer(&name) {
let tables = parse_layer_tables(layer)?;
let geometry = tables
.features
.iter()
.map(|feature| feature_without_tags(feature))
.collect::<Result<Vec<_>, _>>()?;
let tags = tables
.features
.iter()
.map(|feature| {
feature_tags(feature)?
.into_iter()
.map(|(key, value)| {
let key = tables.keys.get(key).ok_or_else(|| {
"MVT feature key index is out of range".to_string()
})?;
let value = tables.values.get(value).ok_or_else(|| {
"MVT feature value index is out of range".to_string()
})?;
Ok(((*key).to_string(), (*value).to_vec()))
})
.collect::<Result<Vec<_>, String>>()
})
.collect::<Result<Vec<_>, String>>()?;
sections.detail_geometry.push((name.clone(), geometry));
sections.detail_tags.push((name, tags));
} else {
sections.base_layers.push((name, layer.to_vec()));
}
}
(100, 2) => sections.fills.push(tile[field.start..field.end].to_vec()),
(101, 2) => sections.faces.extend(face_sections(
&tile[field.payload_start..field.payload_end],
)?),
_ => {}
}
}
Ok(sections)
}
fn verify_detail_tables_compact(tile: &[u8]) -> Result<(), String> {
let mut pos = 0;
while pos < tile.len() {
let field = next_field(tile, &mut pos)?;
if (field.field, field.wire) != (3, 2) {
continue;
}
let layer = &tile[field.payload_start..field.payload_end];
if !is_detail_layer(layer_name(layer)?) {
continue;
}
let tables = parse_layer_tables(layer)?;
if tables.keys.iter().any(|key| !detail_key_allowed(key)) {
return Err("dropped detail tag key remains".to_string());
}
let mut used_keys = vec![false; tables.keys.len()];
let mut used_values = vec![false; tables.values.len()];
for feature in &tables.features {
for (key, value) in feature_tags(feature)? {
let key = used_keys
.get_mut(key)
.ok_or_else(|| "MVT feature key index is out of range".to_string())?;
let value = used_values
.get_mut(value)
.ok_or_else(|| "MVT feature value index is out of range".to_string())?;
*key = true;
*value = true;
}
}
if used_keys.iter().any(|used| !used) || used_values.iter().any(|used| !used) {
return Err("detail tag table contains an unreferenced entry".to_string());
}
if tables.keys.iter().copied().collect::<BTreeSet<_>>().len() != tables.keys.len()
|| tables
.values
.iter()
.copied()
.collect::<BTreeSet<_>>()
.len()
!= tables.values.len()
{
return Err("detail tag table contains a duplicate entry".to_string());
}
}
Ok(())
}
fn verify_kept_sections(before: &[u8], after: &[u8]) -> Result<(), String> {
verify_detail_tables_compact(after)?;
let before = tile_sections(before)?;
let after = tile_sections(after)?;
if before.base_layers != after.base_layers {
return Err("non-osm base layer bytes changed".to_string());
}
if before.detail_geometry != after.detail_geometry {
return Err("detail feature geometry/non-tag fields changed".to_string());
}
let retained_before_tags: Vec<_> = before
.detail_tags
.iter()
.map(|(name, features)| {
(
name,
features
.iter()
.map(|tags| {
tags.iter()
.filter(|(key, _)| detail_key_allowed(key))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>(),
)
})
.collect();
let after_tags: Vec<_> = after
.detail_tags
.iter()
.map(|(name, features)| {
(
name,
features
.iter()
.map(|tags| tags.iter().collect::<Vec<_>>())
.collect::<Vec<_>>(),
)
})
.collect();
if retained_before_tags != after_tags {
return Err("retained detail tag key/value pairs changed".to_string());
}
if before.fills != after.fills {
return Err("field 100 bytes changed".to_string());
}
if before.faces.len() != after.faces.len() {
return Err("field 101 bucket count changed".to_string());
}
for (left, right) in before.faces.iter().zip(&after.faces) {
if left.0 != right.0 || left.1 != right.1 {
return Err("field 101 REGIONS bytes changed".to_string());
}
if left.2 != right.2 {
return Err("field 101 building-group bytes changed".to_string());
}
if !right.3 {
return Err("field 101 shadow stub is not empty".to_string());
}
}
Ok(())
}
pub const TILE_BROTLI_QUALITY: u32 = 11;
const MANIFEST_MAGIC: &[u8; 8] = b"MKRPMF02";
#[derive(Clone, Debug)]
pub enum TileSelection {
All,
HilbertRange { start: u64, end: u64 },
Explicit(BTreeSet<u64>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShardRange {
pub start: usize,
pub end: usize,
}
impl ShardRange {
fn contains(self, shard: usize) -> bool {
(self.start..self.end).contains(&shard)
}
}
impl TileSelection {
fn contains(&self, id: u64) -> bool {
match self {
Self::All => true,
Self::HilbertRange { start, end } => (*start..=*end).contains(&id),
Self::Explicit(ids) => ids.contains(&id),
}
}
fn fingerprint(&self) -> u128 {
let mut bytes = Vec::new();
match self {
Self::All => bytes.push(0),
Self::HilbertRange { start, end } => {
bytes.push(1);
bytes.extend_from_slice(&start.to_le_bytes());
bytes.extend_from_slice(&end.to_le_bytes());
}
Self::Explicit(ids) => {
bytes.push(2);
for id in ids {
bytes.extend_from_slice(&id.to_le_bytes());
}
}
}
content_hash(&bytes)
}
}
#[derive(Clone, Debug)]
pub struct RepackOptions {
pub input: PathBuf,
pub output: PathBuf,
pub selection: TileSelection,
pub dry_run: bool,
pub verify: bool,
pub resume: bool,
pub jobs: usize,
pub brotli_quality: u32,
pub verify_shards: Option<ShardRange>,
pub log: Option<PathBuf>,
}
#[derive(Clone, Debug, Default)]
pub struct RepackReport {
pub shards: u32,
pub tiles: u64,
pub unique_blobs: u64,
pub decoded_before: u64,
pub decoded_after: u64,
pub compressed_before: u64,
pub compressed_after: u64,
pub savings: [u64; DATA_POLICY.len()],
}
impl RepackReport {
fn add_manifest(&mut self, manifest: &ShardManifest) {
self.shards += 1;
self.tiles += manifest.tile_count;
self.unique_blobs += manifest.unique_blobs;
self.decoded_before += manifest.decoded_before;
self.decoded_after += manifest.decoded_after;
self.compressed_before += manifest.compressed_before;
self.compressed_after += manifest.compressed_after;
for (total, shard) in self.savings.iter_mut().zip(manifest.savings) {
*total += shard;
}
}
}
#[derive(Clone, Debug)]
struct ShardManifest {
input_root_hash: u128,
selection_hash: u128,
input_record: u32,
output_shard: u32,
brotli_quality: u32,
file_len: u64,
tile_count: u64,
unique_blobs: u64,
start_tile_id: u64,
end_tile_id: u64,
dir_offset: u64,
dir_len: u64,
decoded_before: u64,
decoded_after: u64,
compressed_before: u64,
compressed_after: u64,
savings: [u64; DATA_POLICY.len()],
min_zoom: u8,
max_zoom: u8,
}
fn shard_path(dir: &Path, shard: u32) -> PathBuf {
dir.join(format!("tiles-{shard:03}.mkshard"))
}
fn manifest_path(dir: &Path, shard: u32) -> PathBuf {
dir.join(format!("tiles-{shard:03}.mkrepack"))
}
fn encode_manifest(manifest: &ShardManifest) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(MANIFEST_MAGIC);
out.extend_from_slice(&manifest.input_root_hash.to_le_bytes());
out.extend_from_slice(&manifest.selection_hash.to_le_bytes());
out.extend_from_slice(&manifest.input_record.to_le_bytes());
out.extend_from_slice(&manifest.output_shard.to_le_bytes());
out.extend_from_slice(&manifest.brotli_quality.to_le_bytes());
for value in [
manifest.file_len,
manifest.tile_count,
manifest.unique_blobs,
manifest.start_tile_id,
manifest.end_tile_id,
manifest.dir_offset,
manifest.dir_len,
manifest.decoded_before,
manifest.decoded_after,
manifest.compressed_before,
manifest.compressed_after,
] {
out.extend_from_slice(&value.to_le_bytes());
}
for value in manifest.savings {
out.extend_from_slice(&value.to_le_bytes());
}
out.push(manifest.min_zoom);
out.push(manifest.max_zoom);
out
}
fn decode_manifest(bytes: &[u8]) -> Result<ShardManifest, String> {
let expected_len = 8 + 16 + 16 + 4 + 4 + 4 + 11 * 8 + DATA_POLICY.len() * 8 + 2;
if bytes.len() != expected_len || bytes.get(..8) != Some(MANIFEST_MAGIC) {
return Err("invalid repack shard manifest".to_string());
}
let mut pos = 8;
let take_u128 = |pos: &mut usize| {
let value = u128::from_le_bytes(bytes[*pos..*pos + 16].try_into().unwrap());
*pos += 16;
value
};
let take_u32 = |pos: &mut usize| {
let value = u32::from_le_bytes(bytes[*pos..*pos + 4].try_into().unwrap());
*pos += 4;
value
};
let take_u64 = |pos: &mut usize| {
let value = u64::from_le_bytes(bytes[*pos..*pos + 8].try_into().unwrap());
*pos += 8;
value
};
let input_root_hash = take_u128(&mut pos);
let selection_hash = take_u128(&mut pos);
let input_record = take_u32(&mut pos);
let output_shard = take_u32(&mut pos);
let brotli_quality = take_u32(&mut pos);
let file_len = take_u64(&mut pos);
let tile_count = take_u64(&mut pos);
let unique_blobs = take_u64(&mut pos);
let start_tile_id = take_u64(&mut pos);
let end_tile_id = take_u64(&mut pos);
let dir_offset = take_u64(&mut pos);
let dir_len = take_u64(&mut pos);
let decoded_before = take_u64(&mut pos);
let decoded_after = take_u64(&mut pos);
let compressed_before = take_u64(&mut pos);
let compressed_after = take_u64(&mut pos);
let mut savings = [0; DATA_POLICY.len()];
for value in &mut savings {
*value = take_u64(&mut pos);
}
let min_zoom = bytes[pos];
let max_zoom = bytes[pos + 1];
Ok(ShardManifest {
input_root_hash,
selection_hash,
input_record,
output_shard,
brotli_quality,
file_len,
tile_count,
unique_blobs,
start_tile_id,
end_tile_id,
dir_offset,
dir_len,
decoded_before,
decoded_after,
compressed_before,
compressed_after,
savings,
min_zoom,
max_zoom,
})
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
let partial = path.with_extension("partial");
match fs::remove_file(&partial) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("remove {}: {err}", partial.display())),
}
let mut file = File::create(&partial)
.map_err(|err| format!("create {}: {err}", partial.display()))?;
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|err| format!("write {}: {err}", partial.display()))?;
fs::rename(&partial, path)
.map_err(|err| format!("rename {} to {}: {err}", partial.display(), path.display()))
}
fn selected_refs(
reader: &mut MkmapReader,
record: usize,
selection: &TileSelection,
) -> Result<Vec<MkmapTileRef>, String> {
let mut refs = Vec::new();
reader
.for_each_root_record_tile_ref(record, |tile| {
if selection.contains(tile.tile_id) {
refs.push(tile);
}
})
.map_err(|err| format!("read input root record {record}: {err}"))?;
Ok(refs)
}
fn selection_records(
reader: &mut MkmapReader,
selection: &TileSelection,
) -> Result<Vec<usize>, String> {
if matches!(selection, TileSelection::All) {
return Ok((0..reader.root_record_count()).collect());
}
let mut records = Vec::new();
for record in 0..reader.root_record_count() {
if !selected_refs(reader, record, selection)?.is_empty() {
records.push(record);
}
}
Ok(records)
}
fn load_completed_manifest(
output: &Path,
input_root_hash: u128,
selection_hash: u128,
input_record: u32,
output_shard: u32,
brotli_quality: u32,
) -> Result<Option<ShardManifest>, String> {
let path = manifest_path(output, output_shard);
let shard = shard_path(output, output_shard);
if !path.exists() || !shard.exists() {
return Ok(None);
}
let bytes = fs::read(&path).map_err(|err| format!("read {}: {err}", path.display()))?;
let Ok(manifest) = decode_manifest(&bytes) else {
return Ok(None);
};
let actual_len = fs::metadata(&shard)
.map_err(|err| format!("stat {}: {err}", shard.display()))?
.len();
if manifest.input_root_hash != input_root_hash
|| manifest.selection_hash != selection_hash
|| manifest.input_record != input_record
|| manifest.output_shard != output_shard
|| manifest.brotli_quality != brotli_quality
|| manifest.file_len != actual_len
|| manifest.dir_offset.checked_add(manifest.dir_len) != Some(actual_len)
{
return Ok(None);
}
Ok(Some(manifest))
}
struct ProgressLog {
file: Option<BufWriter<File>>,
}
impl ProgressLog {
fn open(path: Option<&Path>) -> Result<Self, String> {
let file = path
.map(|path| {
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map(BufWriter::new)
.map_err(|err| format!("open progress log {}: {err}", path.display()))
})
.transpose()?;
Ok(Self { file })
}
fn line(&mut self, line: &str) -> Result<(), String> {
println!("{line}");
if let Some(file) = &mut self.file {
writeln!(file, "{line}")
.and_then(|_| file.flush())
.map_err(|err| format!("write progress log: {err}"))?;
}
Ok(())
}
}
fn display_duration(duration: Duration) -> String {
let seconds = duration.as_secs();
format!("{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60)
}
fn process_shard(
reader: &mut MkmapReader,
input_record: usize,
output_shard: u32,
refs: &[MkmapTileRef],
codec: &TileCodec,
dict: Option<&[u8]>,
output: Option<&Path>,
input_root_hash: u128,
selection_hash: u128,
jobs: usize,
brotli_quality: u32,
) -> Result<ShardManifest, String> {
if refs.is_empty() {
return Err("cannot write an empty output shard".to_string());
}
let final_path = output.map(|dir| shard_path(dir, output_shard));
let partial_path = final_path.as_ref().map(|path| path.with_extension("partial"));
if let Some(path) = partial_path.as_ref() {
match fs::remove_file(path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("remove {}: {err}", path.display())),
}
}
let file = match partial_path.as_ref() {
Some(path) => Some(
File::create(path).map_err(|err| format!("create {}: {err}", path.display()))?,
),
None => None,
};
let mut writer = file.map(|file| BufWriter::with_capacity(4 * 1024 * 1024, file));
let mut offset = 0_u64;
let mut entries = Vec::with_capacity(refs.len());
let mut dedup: HashMap<u128, BlobRef> = HashMap::new();
let mut tile_stats = TileRewriteStats::default();
let mut compressed_before = 0_u64;
let mut compressed_after = 0_u64;
let mut min_zoom = u8::MAX;
let mut max_zoom = 0_u8;
struct TileOutput {
tile: MkmapTileRef,
source_len: u64,
compressed: Vec<u8>,
stats: TileRewriteStats,
}
let jobs = jobs.max(1);
let window = jobs.saturating_mul(2).max(1);
let pipeline_result = std::thread::scope(|scope| -> Result<(), String> {
let (job_tx, job_rx) = sync_channel::<(usize, MkmapTileRef, Vec<u8>)>(jobs);
let job_rx = Arc::new(Mutex::new(job_rx));
let (result_tx, result_rx) =
sync_channel::<(usize, Result<TileOutput, String>)>(jobs);
let (permit_tx, permit_rx) = sync_channel::<()>(window);
for _ in 0..window {
permit_tx.send(()).unwrap();
}
let producer = scope.spawn(move || -> Result<(), String> {
for (seq, tile) in refs.iter().copied().enumerate() {
permit_rx
.recv()
.map_err(|_| "tile pipeline stopped before input was read".to_string())?;
let source = reader.read_tile_ref(&tile).map_err(|err| {
format!("read z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y)
})?;
job_tx
.send((seq, tile, source))
.map_err(|_| "tile pipeline stopped before input was queued".to_string())?;
}
Ok(())
});
let mut workers = Vec::with_capacity(jobs);
for _ in 0..jobs {
let job_rx = Arc::clone(&job_rx);
let result_tx = result_tx.clone();
workers.push(scope.spawn(move || -> Result<(), String> {
loop {
let job = job_rx
.lock()
.map_err(|_| "tile job queue mutex poisoned".to_string())?
.recv();
let Ok((seq, tile, source)) = job else {
return Ok(());
};
let result = (|| {
let decoded = codec.decode(&source).map_err(|err| {
format!("decode z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y)
})?;
let (rewritten, stats) = rewrite_tile(&decoded).map_err(|err| {
format!("rewrite z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y)
})?;
let compressed = compress_tile(
&TileCompression::Brotli {
quality: brotli_quality,
},
dict,
&rewritten,
)
.map_err(|err| {
format!("compress z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y)
})?;
Ok(TileOutput {
tile,
source_len: source.len() as u64,
compressed,
stats,
})
})();
result_tx
.send((seq, result))
.map_err(|_| "tile result writer stopped".to_string())?;
}
}));
}
drop(result_tx);
let mut next = 0_usize;
let mut pending = BTreeMap::new();
let mut first_error = None;
for (seq, result) in result_rx {
pending.insert(seq, result);
while let Some(result) = pending.remove(&next) {
match result {
Ok(result) if first_error.is_none() => {
let hash = content_hash(&result.compressed);
let blob = if let Some(blob) = dedup.get(&hash) {
*blob
} else {
let blob = BlobRef {
shard: output_shard,
offset,
len: result.compressed.len() as u64,
};
if let Some(writer) = writer.as_mut() {
writer.write_all(&result.compressed).map_err(|err| {
format!("write output shard {output_shard}: {err}")
})?;
}
offset += result.compressed.len() as u64;
dedup.insert(hash, blob);
blob
};
entries.push(LeafEntry {
tile_id: result.tile.tile_id,
blob,
});
tile_stats.add_assign(&result.stats);
compressed_before += result.source_len;
compressed_after += result.compressed.len() as u64;
min_zoom = min_zoom.min(result.tile.zoom);
max_zoom = max_zoom.max(result.tile.zoom);
}
Ok(_) => {}
Err(error) => {
if first_error.is_none() {
first_error = Some(error);
}
}
}
next += 1;
let _ = permit_tx.send(());
}
}
let producer_result = producer
.join()
.map_err(|_| "tile input thread panicked".to_string())?;
for worker in workers {
worker
.join()
.map_err(|_| "tile worker thread panicked".to_string())??;
}
producer_result?;
if next != refs.len() {
return Err(format!(
"tile pipeline returned {next} of {} results",
refs.len()
));
}
if let Some(error) = first_error {
return Err(error);
}
Ok(())
});
pipeline_result?;
let directory = encode_leaf_directory(&entries)?;
let dir_offset = offset;
let dir_len = directory.len() as u64;
let file_len = dir_offset + dir_len;
if file_len >= SHARD_HARD_CAP {
return Err(format!(
"output shard {output_shard} is {file_len} bytes, cap {SHARD_HARD_CAP}"
));
}
if let Some(mut writer) = writer {
writer
.write_all(&directory)
.and_then(|_| writer.flush())
.map_err(|err| format!("finish output shard {output_shard}: {err}"))?;
writer
.get_ref()
.sync_all()
.map_err(|err| format!("sync output shard {output_shard}: {err}"))?;
drop(writer);
let partial = partial_path.as_ref().unwrap();
let final_path = final_path.as_ref().unwrap();
fs::rename(partial, final_path).map_err(|err| {
format!("rename {} to {}: {err}", partial.display(), final_path.display())
})?;
}
let manifest = ShardManifest {
input_root_hash,
selection_hash,
input_record: input_record as u32,
output_shard,
brotli_quality,
file_len,
tile_count: entries.len() as u64,
unique_blobs: dedup.len() as u64,
start_tile_id: entries.first().unwrap().tile_id,
end_tile_id: entries.last().unwrap().tile_id,
dir_offset,
dir_len,
decoded_before: tile_stats.decoded_before,
decoded_after: tile_stats.decoded_after,
compressed_before,
compressed_after,
savings: tile_stats.savings,
min_zoom,
max_zoom,
};
if let Some(output) = output {
write_atomic(&manifest_path(output, output_shard), &encode_manifest(&manifest))?;
}
Ok(manifest)
}
fn verify_archives(
input: &Path,
output: &Path,
selection: &TileSelection,
shard_range: Option<ShardRange>,
) -> Result<u64, String> {
let mut source = MkmapReader::open(input)
.map_err(|err| format!("open input {}: {err}", input.display()))?;
let mut repacked = MkmapReader::open(output)
.map_err(|err| format!("open output {}: {err}", output.display()))?;
let source_codec = TileCodec::from_metadata(
&source
.get_metadata()
.map_err(|err| format!("input metadata: {err}"))?,
)
.map_err(|err| format!("input codec: {err}"))?;
let output_codec = TileCodec::from_metadata(
&repacked
.get_metadata()
.map_err(|err| format!("output metadata: {err}"))?,
)
.map_err(|err| format!("output codec: {err}"))?;
let records = selection_records(&mut source, selection)?;
if repacked.root_record_count() != records.len() {
return Err(format!(
"output root record count {} differs from selected input count {}",
repacked.root_record_count(),
records.len()
));
}
if let Some(range) = shard_range {
if range.start >= range.end || range.end > records.len() {
return Err(format!(
"--shards range {}..{} is outside 0..{}",
range.start,
range.end,
records.len()
));
}
}
let mut verified_count = 0_u64;
for (output_record, input_record) in records.into_iter().enumerate() {
if shard_range.is_some_and(|range| !range.contains(output_record)) {
continue;
}
let input_refs = selected_refs(&mut source, input_record, selection)?;
let output_refs = selected_refs(&mut repacked, output_record, &TileSelection::All)?;
if input_refs.len() != output_refs.len() {
return Err(format!(
"shard {output_record} tile count differs: input {} output {}",
input_refs.len(),
output_refs.len()
));
}
for (tile, output_ref) in input_refs.iter().zip(&output_refs) {
if tile.tile_id != output_ref.tile_id {
return Err(format!(
"shard {output_record} tile order differs: input {} output {}",
tile.tile_id, output_ref.tile_id
));
}
let before_blob = source
.read_tile_ref(tile)
.map_err(|err| format!("read input tile {}: {err}", tile.tile_id))?;
let after_blob = repacked
.read_tile_ref(&output_ref)
.map_err(|err| format!("read output tile {}: {err}", tile.tile_id))?;
let before = source_codec
.decode(&before_blob)
.map_err(|err| format!("decode input tile {}: {err}", tile.tile_id))?;
let after = output_codec
.decode(&after_blob)
.map_err(|err| format!("decode output tile {}: {err}", tile.tile_id))?;
let expected = rewrite_tile(&before)
.map_err(|err| format!("verify rewrite tile {}: {err}", tile.tile_id))?
.0;
if expected != after {
return Err(format!(
"verify tile {}: repacked tile differs from the deterministic policy rewrite",
tile.tile_id
));
}
verify_kept_sections(&before, &after)
.map_err(|err| format!("verify tile {}: {err}", tile.tile_id))?;
verified_count += 1;
}
}
Ok(verified_count)
}
fn write_root_index_atomic(output: &Path, index: &RootIndex<'_>) -> Result<u64, String> {
let final_path = output.join("root.mkidx");
let partial_path = output.join("root.partial");
match fs::remove_file(&partial_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("remove {}: {err}", partial_path.display())),
}
let len = write_root_index(&partial_path, index)?;
File::open(&partial_path)
.and_then(|file| file.sync_all())
.map_err(|err| format!("sync {}: {err}", partial_path.display()))?;
fs::rename(&partial_path, &final_path).map_err(|err| {
format!(
"rename {} to {}: {err}",
partial_path.display(),
final_path.display()
)
})?;
Ok(len)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RepackStatus {
pub completed_shards: usize,
pub total_shards: usize,
pub compressed_before: u64,
pub compressed_after: u64,
}
pub fn repack_status(options: &RepackOptions) -> Result<RepackStatus, String> {
if options.brotli_quality > 11 {
return Err("--brotli-quality must be in 0..=11".to_string());
}
let root_path = options.input.join("root.mkidx");
let root_bytes = fs::read(&root_path)
.map_err(|err| format!("read {}: {err}", root_path.display()))?;
let input_root_hash = content_hash(&root_bytes);
let selection_hash = options.selection.fingerprint();
let mut reader = MkmapReader::open(&options.input)
.map_err(|err| format!("open {}: {err}", options.input.display()))?;
let records = selection_records(&mut reader, &options.selection)?;
let mut status = RepackStatus {
total_shards: records.len(),
..Default::default()
};
if !options.output.exists() {
return Ok(status);
}
for (output_shard, input_record) in records.into_iter().enumerate() {
if let Some(manifest) = load_completed_manifest(
&options.output,
input_root_hash,
selection_hash,
input_record as u32,
output_shard as u32,
options.brotli_quality,
)? {
status.completed_shards += 1;
status.compressed_before += manifest.compressed_before;
status.compressed_after += manifest.file_len;
}
}
Ok(status)
}
pub fn repack_archive(options: &RepackOptions) -> Result<RepackReport, String> {
if options.jobs == 0 {
return Err("--jobs must be at least 1".to_string());
}
if options.brotli_quality > 11 {
return Err("--brotli-quality must be in 0..=11".to_string());
}
if options.verify_shards.is_some() && !options.verify {
return Err("--shards requires --verify".to_string());
}
if options.dry_run && options.verify {
return Err("--verify requires an output archive and cannot be combined with --dry-run"
.to_string());
}
let same_path = options.input == options.output
|| (options.output.exists()
&& fs::canonicalize(&options.input).ok() == fs::canonicalize(&options.output).ok());
if same_path {
return Err("input and output archive directories must differ".to_string());
}
let root_path = options.input.join("root.mkidx");
let root_bytes = fs::read(&root_path)
.map_err(|err| format!("read {}: {err}", root_path.display()))?;
let input_root_hash = content_hash(&root_bytes);
let selection_hash = options.selection.fingerprint();
let mut reader = MkmapReader::open(&options.input)
.map_err(|err| format!("open {}: {err}", options.input.display()))?;
let metadata = reader
.get_metadata()
.map_err(|err| format!("read input metadata: {err}"))?;
let codec = TileCodec::from_metadata(&metadata)
.map_err(|err| format!("read input codec: {err}"))?;
if !codec.is_brotli() {
return Err(format!(
"input codec '{}' is unsupported; repack accepts br and br:dict-v1",
codec.metadata_value()
));
}
let dict = codec.dict().map(<[u8]>::to_vec);
if reader.shared_dict().unwrap_or(&[]) != dict.as_deref().unwrap_or(&[]) {
return Err("root dictionary section differs from codec metadata".to_string());
}
let records = selection_records(&mut reader, &options.selection)?;
if records.is_empty() {
return Err("--tiles selected no input tiles".to_string());
}
if !options.dry_run {
if options.output.exists() && !options.resume {
return Err(format!(
"{} already exists; use --resume or choose a new output",
options.output.display()
));
}
fs::create_dir_all(&options.output)
.map_err(|err| format!("create {}: {err}", options.output.display()))?;
}
if let Some(range) = options.verify_shards {
if range.start >= range.end || range.end > records.len() {
return Err(format!(
"--shards range {}..{} is outside 0..{}",
range.start,
range.end,
records.len()
));
}
}
let mut progress = ProgressLog::open(options.log.as_deref())?;
let total_shards = records.len();
let run_start = Instant::now();
let mut run_shards = 0_u32;
let mut run_tiles = 0_u64;
let mut run_output_bytes = 0_u64;
let mut manifests = Vec::with_capacity(records.len());
let mut report = RepackReport::default();
for (output_index, input_record) in records.into_iter().enumerate() {
let output_shard = output_index as u32;
if options.resume && !options.dry_run {
if let Some(manifest) = load_completed_manifest(
&options.output,
input_root_hash,
selection_hash,
input_record as u32,
output_shard,
options.brotli_quality,
)? {
progress.line(&format!(
"{}/{total_shards} shard {output_shard:03} {} bytes → {} bytes, {} tiles, resumed, running 0.00 MB/s, ETA --:--:--",
output_index + 1,
manifest.compressed_before,
manifest.file_len,
manifest.tile_count,
))?;
report.add_manifest(&manifest);
manifests.push(manifest);
continue;
}
}
let refs = selected_refs(&mut reader, input_record, &options.selection)?;
let shard_start = Instant::now();
let manifest = process_shard(
&mut reader,
input_record,
output_shard,
&refs,
&codec,
dict.as_deref(),
(!options.dry_run).then_some(options.output.as_path()),
input_root_hash,
selection_hash,
options.jobs,
options.brotli_quality,
)?;
let shard_elapsed = shard_start.elapsed();
run_shards += 1;
run_tiles += manifest.tile_count;
run_output_bytes += manifest.file_len;
report.add_manifest(&manifest);
let remaining = total_shards - output_index - 1;
let eta = if run_shards == 0 {
None
} else {
Some(run_start.elapsed().mul_f64(remaining as f64 / f64::from(run_shards)))
};
let mib_per_second = run_output_bytes as f64
/ 1_048_576.0
/ run_start.elapsed().as_secs_f64().max(f64::EPSILON);
progress.line(&format!(
"{}/{total_shards} shard {output_shard:03} {} bytes → {} bytes, {} tiles, {}, running {:.2} MB/s, ETA {}",
output_index + 1,
manifest.compressed_before,
manifest.file_len,
manifest.tile_count,
display_duration(shard_elapsed),
mib_per_second,
eta.map(display_duration).unwrap_or_else(|| "--:--:--".to_string()),
))?;
manifests.push(manifest);
}
if !options.dry_run {
let roots: Vec<_> = manifests
.iter()
.map(|manifest| RootRecord {
start_tile_id: manifest.start_tile_id,
end_tile_id: manifest.end_tile_id,
shard: manifest.output_shard,
dir_offset: manifest.dir_offset,
dir_len: manifest.dir_len,
})
.collect();
let min_zoom = manifests.iter().map(|manifest| manifest.min_zoom).min().unwrap();
let max_zoom = manifests.iter().map(|manifest| manifest.max_zoom).max().unwrap();
write_root_index_atomic(
&options.output,
&RootIndex {
metadata: &metadata,
dict: dict.as_deref(),
shard_cap: SHARD_HARD_CAP,
tile_count: report.tiles,
unique_blobs: report.unique_blobs,
min_zoom,
max_zoom,
records: &roots,
},
)?;
if options.verify {
let verified = verify_archives(
&options.input,
&options.output,
&options.selection,
options.verify_shards,
)?;
progress.line(&format!(
"verify: OK — {verified} tiles decoded once per archive and policy-checked"
))?;
}
}
progress.line(&format!(
"total: {} shards, {} tiles, {} bytes in → {} bytes out, elapsed {}, running {:.2} tiles/s, {:.2} MB/s; {}",
report.shards,
report.tiles,
report.compressed_before,
manifests.iter().map(|manifest| manifest.file_len).sum::<u64>(),
display_duration(run_start.elapsed()),
run_tiles as f64 / run_start.elapsed().as_secs_f64().max(f64::EPSILON),
run_output_bytes as f64 / 1_048_576.0 / run_start.elapsed().as_secs_f64().max(f64::EPSILON),
if options.dry_run { "root.mkidx not written (dry run)" } else { "root.mkidx written at end" },
))?;
println!("action | data | decoded bytes saved | reader / reason");
for (row, saved) in DATA_POLICY.iter().zip(report.savings) {
println!(
"{} | {} | {} | {}",
match row.action {
PolicyAction::Keep => "KEEP",
PolicyAction::Drop => "DROP",
},
row.item,
saved,
row.reader_or_reason
);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../seed-files/amsterdam-tiles")
}
#[test]
fn detail_tables_are_compacted_without_touching_geometry() {
let mut feature = Vec::new();
let mut tags = Vec::new();
for value in [0_u64, 0, 1, 1, 2, 2] {
write_varint(value, &mut tags);
}
write_len_field(2, &tags, &mut feature);
write_len_field(4, &[9, 2, 2, 0], &mut feature);
let mut layer = Vec::new();
write_len_field(1, b"osm_points", &mut layer);
write_len_field(2, &feature, &mut layer);
for key in ["name", "addr:housenumber", "__makepad_osm_id"] {
write_len_field(3, key.as_bytes(), &mut layer);
}
for value in [b"kept".as_slice(), b"gone", b"42"] {
let mut message = Vec::new();
write_len_field(1, value, &mut message);
write_len_field(4, &message, &mut layer);
}
let mut tile = Vec::new();
write_len_field(3, &layer, &mut tile);
let (rewritten, stats) = rewrite_tile(&tile).unwrap();
verify_rewritten_tile(&tile, &rewritten).unwrap();
let sections = tile_sections(&rewritten).unwrap();
let retained_keys: Vec<_> = sections
.detail_tags
.iter()
.flat_map(|(_, features)| features.iter())
.flat_map(|tags| tags.iter().map(|(key, _)| key.as_str()))
.collect();
assert_eq!(retained_keys, ["name"]);
assert!(stats.savings[POLICY_TAGS] > 0);
assert!(stats.savings[POLICY_SYNTHETIC] > 0);
}
fn test_write_shapes(points: &[(i64, i64)], out: &mut Vec<u8>, checksum: &mut u64) {
write_varint(usize::from(!points.is_empty()) as u64, out);
if points.is_empty() {
return;
}
write_varint(1, out);
write_varint(points.len() as u64, out);
let (mut previous_x, mut previous_y) = (0_i64, 0_i64);
for &(x, y) in points {
let delta_x = x - previous_x;
let delta_y = y - previous_y;
write_varint(((delta_x << 1) ^ (delta_x >> 63)) as u64, out);
write_varint(((delta_y << 1) ^ (delta_y >> 63)) as u64, out);
fnv_step(checksum, &x.to_le_bytes());
fnv_step(checksum, &y.to_le_bytes());
previous_x = x;
previous_y = y;
}
}
fn test_faces_tile(version: u8) -> Vec<u8> {
let mut body = Vec::new();
let mut checksum = 0xcbf2_9ce4_8422_2325;
write_varint(1, &mut body);
write_varint(7, &mut body);
test_write_shapes(&[(2, 3), (5, 7)], &mut body, &mut checksum);
test_write_shapes(&[], &mut body, &mut checksum);
test_write_shapes(&[], &mut body, &mut checksum);
body.extend_from_slice(&123_u64.to_le_bytes());
test_write_shapes(&[(9, 11)], &mut body, &mut checksum);
test_write_shapes(&[(1, 2)], &mut body, &mut checksum);
if version == 4 {
body.extend_from_slice(&456_u64.to_le_bytes());
write_varint(1, &mut body);
write_varint(32, &mut body);
write_varint(0xff00ff, &mut body);
test_write_shapes(&[(13, 17), (19, 23)], &mut body, &mut checksum);
}
let mut blob = vec![version];
write_varint(1, &mut blob);
write_varint(14, &mut blob);
blob.extend_from_slice(&99_u64.to_le_bytes());
blob.extend_from_slice(&checksum.to_le_bytes());
write_varint(body.len() as u64, &mut blob);
blob.extend_from_slice(&body);
let mut tile = Vec::new();
write_len_field(101, &blob, &mut tile);
tile
}
#[test]
fn v3_and_v4_faces_keep_regions_and_buildings_but_stub_shadows() {
for version in [3, 4] {
let input = test_faces_tile(version);
let output = rewrite_tile(&input).unwrap().0;
verify_rewritten_tile(&input, &output).unwrap();
let before = tile_sections(&input).unwrap();
let after = tile_sections(&output).unwrap();
assert_eq!(before.faces[0].1, after.faces[0].1);
assert_eq!(before.faces[0].2, after.faces[0].2);
assert!(after.faces[0].3);
assert!(output.len() < input.len());
}
}
#[test]
fn dictionary_archive_roundtrip_preserves_codec() {
let scratch = Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../target/map-repack-dict-test-{}", std::process::id()));
if scratch.exists() {
fs::remove_dir_all(&scratch).unwrap();
}
let input_dir = scratch.join("input.mkmap");
let output_dir = scratch.join("output.mkmap");
fs::create_dir_all(&input_dir).unwrap();
let mut layer = Vec::new();
write_len_field(1, b"water", &mut layer);
let mut tile = Vec::new();
write_len_field(3, &layer, &mut tile);
let dict = b"water layer polygons streets";
let compression = TileCompression::Brotli { quality: 5 };
let compressed = compress_tile(&compression, Some(dict), &tile).unwrap();
let entry = LeafEntry {
tile_id: makepad_mbtile_reader::mkmap_tile_id(0, 0, 0),
blob: BlobRef {
shard: 0,
offset: 0,
len: compressed.len() as u64,
},
};
let directory = encode_leaf_directory(&[entry]).unwrap();
let mut shard = compressed;
let dir_offset = shard.len() as u64;
shard.extend_from_slice(&directory);
fs::write(input_dir.join("tiles-000.mkshard"), shard).unwrap();
let metadata = makepad_mbtile_reader::compression_metadata_rows(
&compression,
Some(dict),
)
.into_iter()
.collect();
write_root_index(
&input_dir.join("root.mkidx"),
&RootIndex {
metadata: &metadata,
dict: Some(dict),
shard_cap: SHARD_HARD_CAP,
tile_count: 1,
unique_blobs: 1,
min_zoom: 0,
max_zoom: 0,
records: &[RootRecord {
start_tile_id: entry.tile_id,
end_tile_id: entry.tile_id,
shard: 0,
dir_offset,
dir_len: directory.len() as u64,
}],
},
)
.unwrap();
repack_archive(&RepackOptions {
input: input_dir,
output: output_dir.clone(),
selection: TileSelection::All,
dry_run: false,
verify: true,
resume: false,
jobs: 2,
brotli_quality: TILE_BROTLI_QUALITY,
verify_shards: None,
log: None,
})
.unwrap();
let mut reader = MkmapReader::open(&output_dir).unwrap();
assert_eq!(reader.shared_dict(), Some(dict.as_slice()));
let tile_ref = reader.resolve_tile(0, 0, 0).unwrap().unwrap();
let stored = reader.read_tile_ref(&tile_ref).unwrap();
let codec = TileCodec::from_metadata(&reader.get_metadata().unwrap()).unwrap();
assert_eq!(codec.metadata_value(), "br:dict-v1");
assert_eq!(codec.decode(&stored).unwrap(), tile);
fs::remove_dir_all(scratch).unwrap();
}
#[test]
fn two_shard_partial_resume_finishes_a_readable_archive() {
let scratch = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!(
"../../target/map-repack-resume-test-{}",
std::process::id()
));
if scratch.exists() {
fs::remove_dir_all(&scratch).unwrap();
}
let input_dir = scratch.join("input.mkmap");
let output_dir = scratch.join("output.mkmap");
fs::create_dir_all(&input_dir).unwrap();
let mut tiles = Vec::new();
for name in [b"water".as_slice(), b"landuse"] {
let mut layer = Vec::new();
write_len_field(1, name, &mut layer);
let mut tile = Vec::new();
write_len_field(3, &layer, &mut tile);
tiles.push(tile);
}
let compression = TileCompression::Brotli { quality: 1 };
let mut records = Vec::new();
for (shard_index, tile) in tiles.iter().enumerate() {
let tile_id = shard_index as u64;
let compressed = compress_tile(&compression, None, tile).unwrap();
let entry = LeafEntry {
tile_id,
blob: BlobRef {
shard: shard_index as u32,
offset: 0,
len: compressed.len() as u64,
},
};
let directory = encode_leaf_directory(&[entry]).unwrap();
let dir_offset = compressed.len() as u64;
let mut shard = compressed;
shard.extend_from_slice(&directory);
fs::write(
input_dir.join(format!("tiles-{shard_index:03}.mkshard")),
shard,
)
.unwrap();
records.push(RootRecord {
start_tile_id: tile_id,
end_tile_id: tile_id,
shard: shard_index as u32,
dir_offset,
dir_len: directory.len() as u64,
});
}
let metadata = [("compression".to_string(), "br".to_string())]
.into_iter()
.collect();
write_root_index(
&input_dir.join("root.mkidx"),
&RootIndex {
metadata: &metadata,
dict: None,
shard_cap: SHARD_HARD_CAP,
tile_count: 2,
unique_blobs: 2,
min_zoom: 0,
max_zoom: 1,
records: &records,
},
)
.unwrap();
let options = RepackOptions {
input: input_dir,
output: output_dir.clone(),
selection: TileSelection::All,
dry_run: false,
verify: true,
resume: false,
jobs: 2,
brotli_quality: 1,
verify_shards: None,
log: Some(output_dir.join("repack.log")),
};
repack_archive(&options).unwrap();
assert_eq!(repack_status(&options).unwrap().completed_shards, 2);
let first_shard = fs::read(output_dir.join("tiles-000.mkshard")).unwrap();
fs::remove_file(output_dir.join("tiles-001.mkrepack")).unwrap();
fs::remove_file(output_dir.join("root.mkidx")).unwrap();
fs::write(output_dir.join("tiles-001.partial"), b"interrupted").unwrap();
let mut resumed = options.clone();
resumed.resume = true;
assert_eq!(repack_status(&resumed).unwrap().completed_shards, 1);
let mut changed_quality = resumed.clone();
changed_quality.brotli_quality = 2;
assert_eq!(
repack_status(&changed_quality).unwrap().completed_shards,
0
);
repack_archive(&resumed).unwrap();
let status = repack_status(&resumed).unwrap();
assert_eq!((status.completed_shards, status.total_shards), (2, 2));
assert_eq!(
verify_archives(
&resumed.input,
&resumed.output,
&resumed.selection,
Some(ShardRange { start: 1, end: 2 }),
)
.unwrap(),
1
);
assert_eq!(
fs::read(output_dir.join("tiles-000.mkshard")).unwrap(),
first_shard
);
let mut reader = MkmapReader::open(&output_dir).unwrap();
let codec = TileCodec::from_metadata(&reader.get_metadata().unwrap()).unwrap();
let mut decoded = Vec::new();
let mut refs = Vec::new();
reader.for_each_tile_ref(|tile| refs.push(tile)).unwrap();
for tile in refs {
decoded.push(codec.decode(&reader.read_tile_ref(&tile).unwrap()).unwrap());
}
assert_eq!(decoded, tiles);
let log = fs::read_to_string(output_dir.join("repack.log")).unwrap();
assert!(log.contains("1/2 shard 000"));
assert!(log.contains("2/2 shard 001"));
assert!(log.contains("root.mkidx written at end"));
fs::remove_dir_all(scratch).unwrap();
}
#[test]
#[ignore = "acceptance fixture: deterministic Brotli q11 over 25 large tiles"]
fn amsterdam_tiles_rewrite_and_verify() {
let fixture_dir = fixture_dir();
let mut paths: Vec<_> = fs::read_dir(fixture_dir)
.expect("Amsterdam fixture directory")
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "decoded"))
.collect();
paths.sort();
assert_eq!(paths.len(), 25);
let compression = TileCompression::Brotli {
quality: TILE_BROTLI_QUALITY,
};
let mut before = 0_u64;
let mut after = 0_u64;
let mut compressed = 0_u64;
let mut shadows = 0_u64;
let mut tags = 0_u64;
for path in paths {
let input = fs::read(&path).unwrap();
let (output, stats) = rewrite_tile(&input)
.unwrap_or_else(|error| panic!("{}: {error}", path.display()));
verify_rewritten_tile(&input, &output)
.unwrap_or_else(|error| panic!("{}: {error}", path.display()));
before += input.len() as u64;
after += output.len() as u64;
compressed += compress_tile(&compression, None, &output).unwrap().len() as u64;
shadows += stats.savings[POLICY_SHADOWS];
tags += stats.savings[POLICY_TAGS] + stats.savings[POLICY_SYNTHETIC];
}
assert!(after < before);
assert!(shadows > 0);
assert!(tags > 0);
println!(
"Amsterdam 25: decoded {before} -> {after} bytes; q{} brotli {} bytes ({:.2} MiB); shadows saved {shadows}; tags saved {tags}",
TILE_BROTLI_QUALITY,
compressed,
compressed as f64 / 1_048_576.0
);
}
fn write_repeated_amsterdam_archive(output: &Path, tile_count: usize) {
fs::create_dir_all(output).unwrap();
let mut paths: Vec<_> = fs::read_dir(fixture_dir())
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "decoded"))
.collect();
paths.sort();
let compression = TileCompression::Brotli { quality: 1 };
let mut shard = Vec::new();
let mut blobs = Vec::new();
for path in paths {
let decoded = fs::read(path).unwrap();
let compressed = compress_tile(&compression, None, &decoded).unwrap();
let blob = BlobRef {
shard: 0,
offset: shard.len() as u64,
len: compressed.len() as u64,
};
shard.extend_from_slice(&compressed);
blobs.push(blob);
}
let first_id = makepad_mbtile_reader::mkmap_tile_id(14, 0, 0);
let entries: Vec<_> = (0..tile_count)
.map(|index| LeafEntry {
tile_id: first_id + index as u64,
blob: blobs[index % blobs.len()],
})
.collect();
let directory = encode_leaf_directory(&entries).unwrap();
let dir_offset = shard.len() as u64;
shard.extend_from_slice(&directory);
fs::write(output.join("tiles-000.mkshard"), shard).unwrap();
let metadata = [("compression".to_string(), "br".to_string())]
.into_iter()
.collect();
write_root_index(
&output.join("root.mkidx"),
&RootIndex {
metadata: &metadata,
dict: None,
shard_cap: SHARD_HARD_CAP,
tile_count: tile_count as u64,
unique_blobs: blobs.len() as u64,
min_zoom: 14,
max_zoom: 14,
records: &[RootRecord {
start_tile_id: entries.first().unwrap().tile_id,
end_tile_id: entries.last().unwrap().tile_id,
shard: 0,
dir_offset,
dir_len: directory.len() as u64,
}],
},
)
.unwrap();
}
#[test]
#[ignore = "acceptance benchmark: six q11 archive repacks"]
fn benchmark_parallel_amsterdam_repack() {
let scratch = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!(
"../../target/map-repack-benchmark-{}",
std::process::id()
));
if scratch.exists() {
fs::remove_dir_all(&scratch).unwrap();
}
fs::create_dir_all(&scratch).unwrap();
for tile_count in [25_usize, 500] {
let input = scratch.join(format!("input-{tile_count}.mkmap"));
write_repeated_amsterdam_archive(&input, tile_count);
for jobs in [1_usize, 4, 16] {
let output = scratch.join(format!("output-{tile_count}-{jobs}.mkmap"));
let start = Instant::now();
let report = repack_archive(&RepackOptions {
input: input.clone(),
output,
selection: TileSelection::All,
dry_run: false,
verify: false,
resume: false,
jobs,
brotli_quality: TILE_BROTLI_QUALITY,
verify_shards: None,
log: None,
})
.unwrap();
let elapsed = start.elapsed().as_secs_f64();
println!(
"BENCH tiles={tile_count} jobs={jobs} elapsed={elapsed:.3}s tiles/s={:.3} output-MiB/s={:.3}",
tile_count as f64 / elapsed,
report.compressed_after as f64 / 1_048_576.0 / elapsed,
);
}
}
fs::remove_dir_all(scratch).unwrap();
}
#[test]
fn manifest_roundtrip_is_fixed_and_deterministic() {
let manifest = ShardManifest {
input_root_hash: 1,
selection_hash: 2,
input_record: 3,
output_shard: 4,
brotli_quality: 11,
file_len: 5,
tile_count: 6,
unique_blobs: 7,
start_tile_id: 8,
end_tile_id: 9,
dir_offset: 10,
dir_len: 11,
decoded_before: 12,
decoded_after: 13,
compressed_before: 14,
compressed_after: 15,
savings: [16; DATA_POLICY.len()],
min_zoom: 14,
max_zoom: 18,
};
let bytes = encode_manifest(&manifest);
let decoded = decode_manifest(&bytes).unwrap();
assert_eq!(encode_manifest(&decoded), bytes);
}
#[test]
#[ignore = "acceptance fixture: builds and verifies a 25-tile mkmap twice"]
fn amsterdam_archive_roundtrip() {
let scratch = Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../target/map-repack-test-{}", std::process::id()));
if scratch.exists() {
fs::remove_dir_all(&scratch).unwrap();
}
let input_dir = scratch.join("input.mkmap");
let output_dir = scratch.join("output.mkmap");
fs::create_dir_all(&input_dir).unwrap();
let fixture_dir = fixture_dir();
let mut paths: Vec<_> = fs::read_dir(fixture_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "decoded"))
.collect();
paths.sort();
let compression = TileCompression::Brotli { quality: 5 };
let mut tiles = Vec::new();
for path in paths {
let stem = path.file_stem().unwrap().to_string_lossy();
let mut parts = stem.split('-');
let zoom = parts.next().unwrap()[1..].parse::<u8>().unwrap();
let x = parts.next().unwrap()[1..].parse::<u32>().unwrap();
let y = parts.next().unwrap()[1..].parse::<u32>().unwrap();
let id = makepad_mbtile_reader::mkmap_tile_id(zoom, x, y);
let decoded = fs::read(path).unwrap();
let compressed = compress_tile(&compression, None, &decoded).unwrap();
tiles.push((id, compressed));
}
tiles.sort_by_key(|tile| tile.0);
let mut shard = Vec::new();
let mut entries = Vec::new();
for (tile_id, compressed) in tiles {
let offset = shard.len() as u64;
shard.extend_from_slice(&compressed);
entries.push(LeafEntry {
tile_id,
blob: BlobRef {
shard: 0,
offset,
len: compressed.len() as u64,
},
});
}
let directory = encode_leaf_directory(&entries).unwrap();
let dir_offset = shard.len() as u64;
shard.extend_from_slice(&directory);
fs::write(input_dir.join("tiles-000.mkshard"), shard).unwrap();
let metadata = [("compression".to_string(), "br".to_string())]
.into_iter()
.collect();
write_root_index(
&input_dir.join("root.mkidx"),
&RootIndex {
metadata: &metadata,
dict: None,
shard_cap: SHARD_HARD_CAP,
tile_count: entries.len() as u64,
unique_blobs: entries.len() as u64,
min_zoom: 14,
max_zoom: 14,
records: &[RootRecord {
start_tile_id: entries.first().unwrap().tile_id,
end_tile_id: entries.last().unwrap().tile_id,
shard: 0,
dir_offset,
dir_len: directory.len() as u64,
}],
},
)
.unwrap();
let options = RepackOptions {
input: input_dir,
output: output_dir.clone(),
selection: TileSelection::All,
dry_run: false,
verify: true,
resume: false,
jobs: 4,
brotli_quality: TILE_BROTLI_QUALITY,
verify_shards: None,
log: None,
};
let report = repack_archive(&options).unwrap();
assert_eq!(report.tiles, 25);
let first_root = fs::read(output_dir.join("root.mkidx")).unwrap();
let first_shard = fs::read(output_dir.join("tiles-000.mkshard")).unwrap();
let mut resumed = options.clone();
resumed.resume = true;
repack_archive(&resumed).unwrap();
assert_eq!(fs::read(output_dir.join("root.mkidx")).unwrap(), first_root);
assert_eq!(
fs::read(output_dir.join("tiles-000.mkshard")).unwrap(),
first_shard
);
// Model a kill after a partial shard write and before its manifest.
fs::remove_file(output_dir.join("tiles-000.mkrepack")).unwrap();
fs::write(output_dir.join("tiles-000.partial"), b"truncated").unwrap();
repack_archive(&resumed).unwrap();
assert_eq!(fs::read(output_dir.join("root.mkidx")).unwrap(), first_root);
assert_eq!(
fs::read(output_dir.join("tiles-000.mkshard")).unwrap(),
first_shard
);
let mut reader = MkmapReader::open(&output_dir).unwrap();
assert_eq!(reader.tile_count(), 25);
assert_eq!(reader.for_each_tile_ref(|_| {}).map(|_| 25).unwrap(), 25);
fs::remove_dir_all(scratch).unwrap();
}
#[cfg(feature = "faces")]
fn assert_float_stream_equal(left: &[f32], right: &[f32], context: &str, stream: &str) {
assert_eq!(left.len(), right.len(), "{context} {stream} length");
for (index, (left, right)) in left.iter().zip(right).enumerate() {
assert_eq!(
left.to_bits(),
right.to_bits(),
"{context} {stream}[{index}]"
);
}
}
#[cfg(feature = "faces")]
fn assert_index_stream_equal(left: &[u32], right: &[u32], context: &str, stream: &str) {
assert_eq!(left.len(), right.len(), "{context} {stream} length");
for (index, (left, right)) in left.iter().zip(right).enumerate() {
assert_eq!(left, right, "{context} {stream}[{index}]");
}
}
#[cfg(feature = "faces")]
fn assert_tile_buffers_byte_equal(
left: &makepad_widgets::map::tile::TileBuffers,
right: &makepad_widgets::map::tile::TileBuffers,
context: &str,
) {
macro_rules! same_indices {
($($field:ident),+ $(,)?) => {$ (
assert_index_stream_equal(
&left.$field,
&right.$field,
context,
stringify!($field),
);
)+ };
}
macro_rules! same_floats {
($($field:ident),+ $(,)?) => {$ (
assert_float_stream_equal(
&left.$field,
&right.$field,
context,
stringify!($field),
);
)+ };
}
macro_rules! same_bytes {
($($field:ident),+ $(,)?) => {$ (
assert_eq!(&left.$field, &right.$field, "{context} {}", stringify!($field));
)+ };
}
same_indices!(
fill_indices,
fill_misc_indices,
casing_indices,
stroke_indices,
icon_indices,
icon_high_indices,
fringe_indices,
fill_3d_indices,
fill_3d_misc_indices,
wall_indices,
tree_indices,
tree_cross_indices,
tree_template_indices,
tree_cross_template_indices,
road_icon_indices,
);
same_floats!(
fill_misc_vertices,
icon_vertices,
icon_high_vertices,
shadow_disc_instances,
fill_3d_misc_vertices,
wall_vertices,
wall_instances,
tree_vertices,
tree_cross_vertices,
tree_template_vertices,
tree_cross_template_vertices,
tree_instances,
road_icon_vertices,
);
same_bytes!(
fill_vertices,
casing_vertices,
stroke_vertices,
fringe_vertices,
fill_3d_vertices,
);
for (name, left, right) in [
("icon_instances", &left.icon_instances, &right.icon_instances),
(
"icon_high_instances",
&left.icon_high_instances,
&right.icon_high_instances,
),
] {
assert_eq!(left.len(), right.len(), "{context} {name} length");
for (index, (left, right)) in left.iter().zip(right).enumerate() {
assert_eq!(left.mesh_slot, right.mesh_slot, "{context} {name} mesh slot");
assert_float_stream_equal(
&left.data,
&right.data,
context,
&format!("{name}[{index}].data"),
);
}
}
assert_eq!(left.labels.len(), right.labels.len(), "{context} labels length");
for (index, (left, right)) in left.labels.iter().zip(&right.labels).enumerate() {
assert_eq!(left, right, "{context} labels[{index}]");
}
assert_eq!(
left.pin_hits.len(),
right.pin_hits.len(),
"{context} pin_hits length"
);
for (index, (left, right)) in left.pin_hits.iter().zip(&right.pin_hits).enumerate() {
assert_eq!(left, right, "{context} pin_hits[{index}]");
}
assert_eq!(left.mode_overlay_only, right.mode_overlay_only, "{context}");
assert_eq!(left.feature_count, right.feature_count, "{context}");
assert_eq!(left.render_zoom, right.render_zoom, "{context}");
}
#[cfg(feature = "faces")]
#[test]
#[ignore = "acceptance test: 25 tiles x render zooms 14-18 x 2D/3D"]
fn amsterdam_bake_parity() {
use makepad_widgets::map::geometry::TileKey;
use makepad_widgets::map::style::probe_compiled_theme;
use makepad_widgets::map::tile::build_tile_buffers_from_mvt;
let fixture_dir = fixture_dir();
let mut paths: Vec<_> = fs::read_dir(fixture_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "decoded"))
.collect();
paths.sort();
assert_eq!(paths.len(), 25);
let theme = probe_compiled_theme();
let mut builds = 0;
for path in paths {
let stem = path.file_stem().unwrap().to_string_lossy();
let mut parts = stem.split('-');
let zoom = parts.next().unwrap()[1..].parse::<u32>().unwrap();
let x = parts.next().unwrap()[1..].parse::<i32>().unwrap();
let y = parts.next().unwrap()[1..].parse::<i32>().unwrap();
let key = TileKey { z: zoom, x, y };
let original = fs::read(&path).unwrap();
let repacked = rewrite_tile(&original).unwrap().0;
for render_zoom in 14..=18 {
for buildings_3d in [false, true] {
let left = build_tile_buffers_from_mvt(
key,
&original,
Some(&original),
None,
false,
&[],
&theme,
render_zoom,
buildings_3d,
true,
false,
)
.unwrap();
let right = build_tile_buffers_from_mvt(
key,
&repacked,
Some(&repacked),
None,
false,
&[],
&theme,
render_zoom,
buildings_3d,
true,
false,
)
.unwrap();
assert_tile_buffers_byte_equal(
&left,
&right,
&format!("{stem} rz{render_zoom} 3d={buildings_3d}"),
);
builds += 2;
}
}
}
println!("Amsterdam bake parity: {builds} builds renderer-equivalent");
}
}