From 70d065e042b199d7689921e95cfe6d6ac6999efe Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 19:57:24 -0400 Subject: [PATCH 1/4] Report the charts an open or a bake dropped Four paths lost a chart in silence. bakeOneToFile returns void and bails at four points, and the label callback fired for every cell, so a host printed a finished chart for one that was never written. bakeArchive added the attempted count to its progress total before inspecting any result, so the bar reached the end whether the cells loaded or not. openCharts errored only when every cell failed, and openPath dropped a cell it could not read or parse, or one with no extent. tile57_compose_open computed the count it composed and discarded it. The label now fires for a chart that was written, progress counts the cells that produced a backend, and each lost chart prints a line naming it, as a lost tile already does. tile57_info gains skipped_cells and tile57_compose_meta gains skipped, both appended. A host reads them to tell a chart set with a gap in it from a complete one. Baking a directory holding a cell that is not S-57 writes the archives for the rest and names the one it dropped. --- include/tile57.h | 9 ++++++ src/capi.zig | 11 +++++++ src/chart.zig | 65 ++++++++++++++++++++++++++++++++++------- src/compose/compose.zig | 4 +++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 10fbc1d..e6adbc0 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -537,6 +537,11 @@ typedef struct { uint8_t tile_type; /* tile57_tile_type */ int32_t native_scale; bool is_raster; /* tiles are images, not vector tiles */ + uint32_t skipped_cells; /* cells handed to the open that produced no chart. + * The open succeeds while one parses, so a host reads + * this to tell a chart set with a gap in it from a + * complete one. Appended for ABI-append-safety; a + * zeroed struct reads 0. */ } tile57_info; void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); @@ -1463,6 +1468,10 @@ typedef struct { uint8_t min_zoom; uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ uint32_t charts; /* coverage-carrying charts held */ + uint32_t skipped; /* charts handed to the open that embed no usable coverage: + * they own no ground and are absent from every composed + * tile. Appended for ABI-append-safety; 0 on a zeroed + * struct. */ double west, south, east, north; /* union coverage bounds, degrees */ } tile57_compose_meta; diff --git a/src/capi.zig b/src/capi.zig index 22c2b36..39c9e65 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -884,6 +884,10 @@ const CInfo = extern struct { tile_type: u8, // the archive's stored encoding (TILE57_TILE_TYPE_*) native_scale: i32, // embedded compilation scale (1:N); 0 = derive from zoom band is_raster: bool, // the archive stores pictures, not vector tiles + // Cells handed to the open that produced no chart. The open succeeds while + // one parses, so this is what tells a host the set has a gap. Appended for + // ABI-append-safety; a zeroed struct reads 0. + skipped_cells: u32, }; // tile57_tile_type values (keep in sync with tile57.h). @@ -909,6 +913,7 @@ export fn tile57_chart_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { // nothing above tells it apart from a vector chart. Its tiles are images. // tile_type cannot carry this: TILE57_TILE_TYPE_MLT is 2, and 2 is PNG in // the PMTiles header. + o.skipped_cells = s.skipped_cells; o.is_raster = switch (s.tileType()) { .png, .jpeg, .webp, .avif => true, else => false, @@ -1504,6 +1509,10 @@ const CComposeMeta = extern struct { min_zoom: u8, max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) charts: u32, // coverage-carrying charts held + // Charts handed to the open that embed no usable coverage, so they own no + // ground and are absent from every composed tile. Appended for + // ABI-append-safety; a zeroed struct reads 0. + skipped: u32, west: f64, south: f64, east: f64, @@ -1627,6 +1636,7 @@ export fn tile57_compose_open( error.MixedChartKinds => failWith(err, .unsupported, mixed_kinds), else => fail(err, e), }) orelse return failWith(err, .unsupported, "no chart carries per-cell coverage"); + src.skipped = @intCast(n - na); sidecar.refresh(io, src); o.* = src; return OK; @@ -1951,6 +1961,7 @@ export fn tile57_compose_get_meta(handle: ?*compose.ComposeSource, out: ?*CCompo .min_zoom = src.minz, .max_zoom = src.loop_max, .charts = @intCast(src.readers.len), + .skipped = src.skipped, .west = src.bounds[0], .south = src.bounds[1], .east = src.bounds[2], diff --git a/src/chart.zig b/src/chart.zig index 61cae13..77c2839 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -388,13 +388,23 @@ fn cdup(bytes: []const u8) ?[*]u8 { // Peek `relpath`'s bbox+scale; on success append an index-aligned meta + a gpa-owned // copy of the path. Cells that don't read / have no coverage bbox are skipped (both // lists), keeping meta[i] and paths[i] aligned with the streaming cell index. -fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(ChartMeta), paths: *std.ArrayList([]u8)) !void { - const bytes = dir.readFileAlloc(io, relpath, gpa, .limited(MAX_CELL_BYTES)) catch return; +fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(ChartMeta), paths: *std.ArrayList([]u8)) !bool { + const bytes = dir.readFileAlloc(io, relpath, gpa, .limited(MAX_CELL_BYTES)) catch { + std.debug.print("CHART LOST {s}: cell did not read\n", .{relpath}); + return false; + }; defer gpa.free(bytes); - const m = peekAnyMeta(bytes) orelse return; - const bb = m.bounds orelse return; + const m = peekAnyMeta(bytes) orelse { + std.debug.print("CHART LOST {s}: cell did not parse\n", .{relpath}); + return false; + }; + const bb = m.bounds orelse { + std.debug.print("CHART LOST {s}: cell has no extent\n", .{relpath}); + return false; + }; try metas.append(gpa, .{ .west = bb[0], .south = bb[1], .east = bb[2], .north = bb[3], .cscl = m.cscl }); try paths.append(gpa, try gpa.dupe(u8, relpath)); + return true; } // Internal ChartReadFn for a path-backed chart: read cell `index`'s base .000 + its @@ -1155,7 +1165,13 @@ fn bakeFileWorker(ctx: *BakeFileCtx) void { const i = ctx.next.fetchAdd(1, .monotonic); if (i >= ctx.in_paths.len) return; bakeOneToFile(ctx, i); - if (ctx.label) |lb| lb(ctx.progress_ctx, @intCast(i)); // name the chart just finished + // The label names a chart that was written. It fired for every cell + // before, so a host printed a finished chart for one that failed. + if (ctx.ok[i]) { + if (ctx.label) |lb| lb(ctx.progress_ctx, @intCast(i)); + } else { + std.debug.print("CHART LOST {s}: bake produced no archive\n", .{ctx.in_paths[i]}); + } const d = ctx.done.fetchAdd(1, .monotonic) + 1; // attempted count (smooth progress) if (ctx.progress) |cb| { if (!cb(ctx.progress_ctx, d, @intCast(ctx.in_paths.len))) { @@ -2389,6 +2405,10 @@ pub const Chart = struct { /// partition sidecar the bake wrote next to the archives, so a host never /// has to know that file exists. source_path: ?[]u8 = null, + /// Cells handed to the open that produced no chart. The open succeeds while + /// one cell parses, so a host reads this to tell a chart set with a gap in + /// it from a complete one. + skipped_cells: u32 = 0, cache: std.AutoHashMap(u64, []u8), // tile key -> MVT bytes (owned) cache_max: usize = 8192, // Emit the per-feature pick-report attrs (s57/cell) on live-generated tiles. @@ -2459,8 +2479,12 @@ pub const Chart = struct { bake_enc.parallelFor(gpa, cells_in.len, &ow, OpenWork.run); var valid: usize = 0; - for (ok) |k| { - if (k) valid += 1; + for (ok, cells_in) |k, in| { + if (k) { + valid += 1; + } else { + std.debug.print("CHART LOST {s}: cell did not parse\n", .{in.name}); + } } if (valid == 0) return error.InvalidCell; // cells provided, but none parsed @@ -2481,6 +2505,7 @@ pub const Chart = struct { }; src.* = .{ .backend = .{ .cells = .{ .cells = cells, .rules_dir = dir_copy } }, + .skipped_cells = @intCast(cells_in.len - valid), .cache = std.AutoHashMap(u64, []u8).init(gpa), .pick_attrs = pick_attrs, }; @@ -2546,16 +2571,21 @@ pub const Chart = struct { for (paths.items) |p| gpa.free(p); paths.deinit(gpa); } + // Cells the walk found and could not use. The open succeeds on the + // rest, so this is what tells a host the set has a gap. + var skipped: u32 = 0; if (single_file) { - try addPathCell(io, dir, std.fs.path.basename(path), &metas, &paths); + if (!try addPathCell(io, dir, std.fs.path.basename(path), &metas, &paths)) skipped += 1; } else if (dir.readFileAlloc(io, "CATALOG.031", gpa, .limited(MAX_CELL_BYTES))) |cbytes| { defer gpa.free(cbytes); var carena = std.heap.ArenaAllocator.init(gpa); defer carena.deinit(); if (s57.parseCatalog(carena.allocator(), cbytes)) |entries| { for (entries) |e| { - if (e.is_cell) try addPathCell(io, dir, e.path, &metas, &paths); + if (e.is_cell) { + if (!try addPathCell(io, dir, e.path, &metas, &paths)) skipped += 1; + } } } } else |_| { @@ -2564,7 +2594,7 @@ pub const Chart = struct { while (try walker.next(io)) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.path, ".000")) continue; - try addPathCell(io, dir, entry.path, &metas, &paths); + if (!try addPathCell(io, dir, entry.path, &metas, &paths)) skipped += 1; } } if (metas.items.len == 0) return error.OpenFailed; @@ -2573,6 +2603,7 @@ pub const Chart = struct { // Chart owns the PathCtx (Io + Dir + paths) via ls.path_ctx, freed in deinit. const src = try openChartsStreaming(metas.items, pathRead, null, rules_dir, pick_attrs); errdefer src.deinit(); + src.skipped_cells = skipped; const ctx = try gpa.create(PathCtx); errdefer gpa.destroy(ctx); ctx.* = .{ .threaded = threaded, .io = io, .dir = dir, .paths = try paths.toOwnedSlice(gpa) }; @@ -4133,6 +4164,9 @@ pub fn bakeArchive( } var loaded: usize = 0; + // Cells that produced no backend. The bake keeps going, and the count is + // what a host reports instead of a silent gap in the chart set. + var skipped: usize = 0; var band_ord: u8 = 0; // Union sector-figure reach across the baked cells — published as the // archive's "light_reach" metadata so the compositor widens its tile @@ -4184,7 +4218,16 @@ pub fn bakeArchive( @memset(pas, null); var bw = BakeWork{ .sources = sources.items, .outs = outs, .arenas = pas, .rules_dir = dir, .build_geo = bake_enc.cacheGeoForBand(band) }; bake_enc.parallelFor(gpa, sources.items.len, &bw, BakeWork.run); - loaded += idxs.len; + // Count the cells that produced a backend. `idxs.len` counted the ones + // attempted, so progress reached the total whether they loaded or not. + for (outs, idxs) |o, ci| { + if (o != null) { + loaded += 1; + } else { + skipped += 1; + std.debug.print("CHART LOST {s}: parse produced no cell\n", .{cells_in[ci].name}); + } + } if (progress) |cb| if (has_work) cb(user, 0, loaded, cells_in.len, band_ord - 1, band_count, @tagName(band).ptr); var backs = std.ArrayList(bake_enc.Backend).empty; diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 1da7476..a86a5d7 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -668,6 +668,10 @@ pub const ComposeSource = struct { /// is common, and everything above it — clip a feature or stack a picture, /// portray or not — is not. Set at open and never changed. kind: Kind = .vector, + /// Charts handed to the open that embed no usable coverage. They own no + /// ground, so they are absent from every composed tile, and the open + /// succeeds without them. + skipped: u32 = 0, // A files-open owns its readers + mmaps (deinit closes them); a charts-open // borrows them from the charts, which must outlive this source. owns_archives: bool = true, From a63a4206a253a0e90699233cc7b05499abf98dec Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 19:57:24 -0400 Subject: [PATCH 2/4] Report the compositor's skipped charts without moving the meta struct The count went into tile57_compose_meta between charts and west, described as appended for ABI-append-safety. That struct ends on a double and has no spare padding, so the new field moved the bounds and grew the struct: before: sizeof 40, west at 8, north at 32 after: sizeof 48, west at 16, north at 40 tile57_compose_get_meta writes the whole struct, so a host built against the previous header passed a 40-byte object and had 48 bytes written into it, then read its western bound from the count and the padding after it. No field can be added to this struct safely, so the count is now its own call, tile57_compose_skipped. The struct is back to its previous layout, measured against both headers. The tile57_info append is safe: skipped_cells uses the padding after is_raster and the struct stays 96 bytes, measured against both headers. --- bindings/go/compose.go | 12 ++++++++++++ docs/docs/api/compose.md | 5 +++++ include/tile57.h | 13 +++++++++---- src/capi.zig | 11 ++++++----- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 1405c70..92d5ea2 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -173,6 +173,18 @@ func (c *ComposeSource) Meta() ComposeMeta { } } +// Skipped returns the charts handed to the open that embed no usable coverage. +// They own no ground and are absent from every composed tile, so this is what +// tells a complete quilt from one with holes in it. +func (c *ComposeSource) Skipped() uint32 { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return 0 + } + return uint32(C.tile57_compose_skipped(c.ptr)) +} + // Close releases the compositor, then any charts [OpenCompose] opened for it. // Borrowed charts (from [OpenComposeCharts]) stay open. Idempotent. diff --git a/docs/docs/api/compose.md b/docs/docs/api/compose.md index f6c8144..5b594b2 100644 --- a/docs/docs/api/compose.md +++ b/docs/docs/api/compose.md @@ -83,6 +83,11 @@ tile57_status tile57_compose_gpu_scene(tile57_compose *c, double lon, double lat /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); +/* Charts handed to the open that embed no usable coverage: they own no ground + * and are absent from every composed tile, so a host reads this to tell a + * complete quilt from one with holes in it. 0 for a NULL handle. */ +uint32_t tile57_compose_skipped(tile57_compose *c); + /* Serialize the ownership partition to `path` (a sidecar a later * tile57_compose_open loads to skip the build). */ tile57_status tile57_compose_save_partition(tile57_compose *c, const char *path, diff --git a/include/tile57.h b/include/tile57.h index e6adbc0..5bfcd0c 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1468,10 +1468,6 @@ typedef struct { uint8_t min_zoom; uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ uint32_t charts; /* coverage-carrying charts held */ - uint32_t skipped; /* charts handed to the open that embed no usable coverage: - * they own no ground and are absent from every composed - * tile. Appended for ABI-append-safety; 0 on a zeroed - * struct. */ double west, south, east, north; /* union coverage bounds, degrees */ } tile57_compose_meta; @@ -1644,6 +1640,15 @@ tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, do /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); +/* Charts handed to the open that embed no usable coverage: they own no ground + * and are absent from every composed tile, so a host reads this to tell a + * complete quilt from one with holes in it. 0 for a NULL handle. + * + * A call rather than a tile57_compose_meta field: that struct ends on a double + * with no padding left, so any field added to it moves the bounds and grows the + * struct a host already allocates. */ +uint32_t tile57_compose_skipped(tile57_compose *c); + /* The deepest zoom the chart covering (lon,lat) can serve (its native window + * overscale fill-up). A host caps its per-view zoom-in here so it never magnifies * past that chart into nodata — unlike compose_meta.max_zoom, which is the diff --git a/src/capi.zig b/src/capi.zig index 39c9e65..5546f37 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -1509,10 +1509,6 @@ const CComposeMeta = extern struct { min_zoom: u8, max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) charts: u32, // coverage-carrying charts held - // Charts handed to the open that embed no usable coverage, so they own no - // ground and are absent from every composed tile. Appended for - // ABI-append-safety; a zeroed struct reads 0. - skipped: u32, west: f64, south: f64, east: f64, @@ -1961,7 +1957,6 @@ export fn tile57_compose_get_meta(handle: ?*compose.ComposeSource, out: ?*CCompo .min_zoom = src.minz, .max_zoom = src.loop_max, .charts = @intCast(src.readers.len), - .skipped = src.skipped, .west = src.bounds[0], .south = src.bounds[1], .east = src.bounds[2], @@ -1969,6 +1964,12 @@ export fn tile57_compose_get_meta(handle: ?*compose.ComposeSource, out: ?*CCompo }; } +/// Charts handed to the open that embed no usable coverage. See tile57.h. +export fn tile57_compose_skipped(handle: ?*compose.ComposeSource) callconv(.c) u32 { + const src = handle orelse return 0; + return src.skipped; +} + /// The deepest zoom the chart covering (lon,lat) can serve — the host caps its /// per-view zoom-in here so it never magnifies past that chart into nodata. Falls /// back to the library max where the point covers no cell. See tile57.h. From 1a349fb0af3af2c619faf3460814851059f06000 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 20:44:26 -0400 Subject: [PATCH 3/4] Count a compositor's skipped charts from what the open kept The count was the charts with no decoded coverage, taken before the open ran. openBorrowed drops more after that: a chart whose coverage decoded to an empty ring owns no ground and is absent from every composed tile. Those went uncounted, so charts plus skipped came to less than the number handed in, and a chart missing from the quilt had no sign of it. Both openers now subtract what the open kept from what it was handed, so the two numbers add up by construction. The raster opener never set the count at all and reported 0 whatever it dropped. The bake's own skipped counter is removed. It was incremented and never read, under a comment saying a host reports it. The CHART LOST line beside it is the report. --- src/capi.zig | 6 +++++- src/chart.zig | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/capi.zig b/src/capi.zig index 5546f37..1942858 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -1632,7 +1632,10 @@ export fn tile57_compose_open( error.MixedChartKinds => failWith(err, .unsupported, mixed_kinds), else => fail(err, e), }) orelse return failWith(err, .unsupported, "no chart carries per-cell coverage"); - src.skipped = @intCast(n - na); + // What the open kept, subtracted from what it was handed. Counting the + // charts with no decoded coverage missed the ones openBorrowed drops later + // for an empty coverage ring, so charts + skipped came to less than n. + src.skipped = @intCast(n - src.readers.len); sidecar.refresh(io, src); o.* = src; return OK; @@ -1731,6 +1734,7 @@ export fn tile57_compose_rasters( error.MixedChartKinds => failWith(err, .unsupported, mixed_kinds), else => fail(err, e), }) orelse return failWith(err, .unsupported, "no raster chart carries a compilation scale and coverage"); + src.skipped = @intCast(n - src.readers.len); o.* = src; return OK; } diff --git a/src/chart.zig b/src/chart.zig index 77c2839..0e09ab5 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -4164,9 +4164,6 @@ pub fn bakeArchive( } var loaded: usize = 0; - // Cells that produced no backend. The bake keeps going, and the count is - // what a host reports instead of a silent gap in the chart set. - var skipped: usize = 0; var band_ord: u8 = 0; // Union sector-figure reach across the baked cells — published as the // archive's "light_reach" metadata so the compositor widens its tile @@ -4224,7 +4221,8 @@ pub fn bakeArchive( if (o != null) { loaded += 1; } else { - skipped += 1; + // The bake keeps going. This line is the report: a cell absent + // from the archive with no word for it reads as empty ocean. std.debug.print("CHART LOST {s}: parse produced no cell\n", .{cells_in[ci].name}); } } From 8874b1b8dee9a3d1be72a47d08f0f6f277666f40 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 20:49:36 -0400 Subject: [PATCH 4/4] Say where tile57_info.skipped_cells is filled The field is set when a chart opens from ENC source. Every open this header exports is archive-backed, so a host calling tile57_chart_get_info reads 0 there whatever the source set held, and the text promising a way to tell a set with a gap from a complete one described a value no caller can reach. The comment now says where it is filled and points at the per-cell lines the engine writes to stderr, the report a host gets today. --- include/tile57.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 5bfcd0c..4384037 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -538,9 +538,12 @@ typedef struct { int32_t native_scale; bool is_raster; /* tiles are images, not vector tiles */ uint32_t skipped_cells; /* cells handed to the open that produced no chart. - * The open succeeds while one parses, so a host reads - * this to tell a chart set with a gap in it from a - * complete one. Appended for ABI-append-safety; a + * The open succeeds while one parses, so this tells a + * chart set with a gap in it from a complete one. + * Set on a chart opened from ENC source. The opens + * this header exports are archive-backed, where it + * stays 0; the engine names each dropped cell on + * stderr as it goes. Appended for ABI-append-safety; a * zeroed struct reads 0. */ } tile57_info; void tile57_chart_get_info(tile57_chart *chart, tile57_info *out);