   700→        auto new_ap = read_file(new_dir + "/admin_polygons.bin");
   701→        auto old_av = read_file(old_dir + "/admin_vertices.bin");
   702→        auto new_av = read_file(new_dir + "/admin_vertices.bin");
   703→        MergeSequence av_seq;
   704→        size_t ap_oi = 0, ap_ni = 0;
   705→        size_t appos = 0;
   706→        while (appos < ap_seq.data.size()) {
   707→            uint8_t op = static_cast<uint8_t>(ap_seq.data[appos]); appos++;
   708→            uint32_t count; memcpy(&count, ap_seq.data.data() + appos, 4); appos += 4;
   709→            if (op == OP_MATCH_RUN) {
   710→                for (uint32_t k = 0; k < count; k++) {
   711→                    uint32_t old_vo, vc;
   712→                    memcpy(&old_vo, old_ap.data() + (ap_oi + k) * admin_stride, 4);
   713→                    memcpy(&vc, old_ap.data() + (ap_oi + k) * admin_stride + 4, 4);
   714→                    uint32_t new_vo;
   715→                    memcpy(&new_vo, new_ap.data() + (ap_ni + k) * admin_stride, 4);
   716→                    if (vc > 0) {
   717→                        bool verts_match = (old_vo + vc) * 8 <= old_av.size() &&
   718→                                          (new_vo + vc) * 8 <= new_av.size() &&
   719→                                          memcmp(old_av.data() + old_vo * 8,
   720→                                                 new_av.data() + new_vo * 8, vc * 8) == 0;
   721→                        if (verts_match) {
   722→                            av_seq.add_match(vc);
   723→                        } else {
   724→                            av_seq.add_delete(vc);
   725→                            av_seq.add_insert(new_av.data() + new_vo * 8, vc, 8);
   726→                        }
   727→                    }
   728→                }
   729→                ap_oi += count; ap_ni += count;
   730→            } else if (op == OP_INSERT_RUN) {
   731→                for (uint32_t k = 0; k < count; k++) {
   732→                    const char* rec = ap_seq.data.data() + appos + k * admin_stride;
   733→                    uint32_t vo, vc; memcpy(&vo, rec, 4); memcpy(&vc, rec + 4, 4);
   734→                    if (vc > 0 && (vo + vc) * 8 <= new_av.size())
   735→                        av_seq.add_insert(new_av.data() + vo * 8, vc, 8);
   736→                }
   737→                appos += count * admin_stride;
   738→                ap_ni += count;
   739→            } else if (op == OP_DELETE_RUN) {
   740→                for (uint32_t k = 0; k < count; k++) {
   741→                    uint32_t vc; memcpy(&vc, old_ap.data() + (ap_oi + k) * admin_stride + 4, 4);
   742→                    if (vc > 0) av_seq.add_delete(vc);
   743→                }
   744→                ap_oi += count;
   745→            }
   746→        }
   747→        uint32_t fid = static_cast<uint32_t>(PatchFileId::ADMIN_VERTICES);
   748→        uint64_t os = old_av.size(), ns = new_av.size(), ss = av_seq.data.size();
   749→        uint32_t st = 8, n_fixups = 0;
   750→        wval(patch, &fid, 4); wval(patch, &st, 4);
   751→        wval(patch, &os, 8); wval(patch, &ns, 8);
   752→        wval(patch, &n_fixups, 4); wval(patch, &ss, 8);
   753→        patch.insert(patch.end(), av_seq.data.begin(), av_seq.data.end());
   754→        std::cerr << "  admin_vertices.bin: parent-derived seq=" << ss << " bytes ("
   755→                  << std::fixed << std::setprecision(2) << (ns > 0 ? ss * 100.0 / ns : 0) << "%)" << std::endl;
   756→    }
   757→
   758→    // --- Section: Entry/cell files as full new data ---
   759→    // These are small relative to data files and rebuilt by patch tool isn't reliable.
   760→    // Include them as full replacement. zstd transport compression handles the rest.
   761→    // Entry/cell files: NOT included.
   762→    // The patch tool reconstructs them from the derived ID remaps
   763→    // (extracted implicitly from the merge sequences above).
   764→    // geo_cells.bin, admin_cells.bin, and all entry files are rebuilt.
   765→    std::cerr << "  (entry/cell files: omitted, rebuilt by patch tool)" << std::endl;
   766→
   767→    // Include cell_id changes for entry reconstruction
   768→    {
   769→        // Geo cell changes
   770→        auto new_geo = read_file(new_dir + "/geo_cells.bin");
   771→        auto old_geo = read_file(old_dir + "/geo_cells.bin");
   772→        std::unordered_set<uint64_t> old_set, new_set;
   773→        for (size_t i = 0; i < old_geo.size() / 20; i++) {
   774→            uint64_t c; memcpy(&c, old_geo.data() + i * 20, 8); old_set.insert(c);
   775→        }
   776→        for (size_t i = 0; i < new_geo.size() / 20; i++) {
   777→            uint64_t c; memcpy(&c, new_geo.data() + i * 20, 8); new_set.insert(c);
   778→        }
   779→        std::vector<uint64_t> g_added, g_removed;
   780→        for (auto c : new_set) if (!old_set.count(c)) g_added.push_back(c);
   781→        for (auto c : old_set) if (!new_set.count(c)) g_removed.push_back(c);
   782→        std::sort(g_added.begin(), g_added.end());
   783→        std::sort(g_removed.begin(), g_removed.end());
   784→
   785→        uint32_t marker = CELL_CHANGES_GEO_MARKER;
   786→        uint32_t na = g_added.size(), nr = g_removed.size();
   787→        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
   788→        for (auto c : g_added) wval(patch, &c, 8);
   789→        for (auto c : g_removed) wval(patch, &c, 8);
   790→        std::cerr << "  Geo cell changes: +" << na << " -" << nr << " ("
   791→                  << (na + nr) * 8 << " bytes)" << std::endl;
   792→
   793→        // Admin cell changes
   794→        auto new_ac = read_file(new_dir + "/admin_cells.bin");
   795→        auto old_ac = read_file(old_dir + "/admin_cells.bin");
   796→        old_set.clear(); new_set.clear();
   797→        for (size_t i = 0; i < old_ac.size() / 12; i++) {
   798→            uint64_t c; memcpy(&c, old_ac.data() + i * 12, 8); old_set.insert(c);
   799→        }
   800→        for (size_t i = 0; i < new_ac.size() / 12; i++) {
   801→            uint64_t c; memcpy(&c, new_ac.data() + i * 12, 8); new_set.insert(c);
   802→        }
   803→        std::vector<uint64_t> a_added, a_removed;
   804→        for (auto c : new_set) if (!old_set.count(c)) a_added.push_back(c);
   805→        for (auto c : old_set) if (!new_set.count(c)) a_removed.push_back(c);
   806→        std::sort(a_added.begin(), a_added.end());
   807→        std::sort(a_removed.begin(), a_removed.end());
   808→
   809→        marker = CELL_CHANGES_ADMIN_MARKER;
   810→        na = a_added.size(); nr = a_removed.size();
   811→        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
   812→        for (auto c : a_added) wval(patch, &c, 8);
   813→        for (auto c : a_removed) wval(patch, &c, 8);
   814→        std::cerr << "  Admin cell changes: +" << na << " -" << nr << std::endl;
   815→
   816→        // Derive ID remaps from stored merge sequences (same as patch tool does)
   817→        auto derive_remap = [&](PatchFileId id, size_t old_size, size_t stride) -> std::unordered_map<uint32_t,uint32_t> {
   818→            auto it = stored_merges.find(static_cast<uint32_t>(id));
   819→            if (it == stored_merges.end()) return {};
   820→            auto vec = derive_id_remap_from_merge(it->second, old_size / stride, stride);
   821→            std::unordered_map<uint32_t,uint32_t> m;
   822→            for (uint32_t i = 0; i < vec.size(); i++)
   823→                if (vec[i] != 0xFFFFFFFF) m[i] = vec[i];
   824→            return m;
   825→        };
   826→
   827→        auto w_rm_d = derive_remap(PatchFileId::STREET_WAYS,
   828→            read_file(old_dir + "/street_ways.bin").size(), way_stride);
   829→        auto a_rm_d = derive_remap(PatchFileId::ADDR_POINTS,
   830→            read_file(old_dir + "/addr_points.bin").size(), 16);
   831→        auto i_rm_d = derive_remap(PatchFileId::INTERP_WAYS,
   832→            read_file(old_dir + "/interp_ways.bin").size(), interp_stride);
   833→        auto ad_rm_d = derive_remap(PatchFileId::ADMIN_POLYGONS,
   834→            read_file(old_dir + "/admin_polygons.bin").size(), admin_stride);
   835→
   836→        // Rebuild entries using derived remaps (same as patch tool)
   837→        auto old_se2 = read_file(old_dir + "/street_entries.bin");
   838→        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
   839→        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");
   840→        auto derived = rebuild_geo_from_remap(old_geo, old_se2, old_ae2, old_ie2,
   841→                                               w_rm_d, a_rm_d, i_rm_d, g_added, g_removed);
   842→        auto old_admc = read_file(old_dir + "/admin_cells.bin");
   843→        auto old_adme = read_file(old_dir + "/admin_entries.bin");
   844→        auto admin_derived = rebuild_admin_from_remap(old_admc, old_adme, ad_rm_d, a_added, a_removed);
   845→
   846→        // Cell-level entry corrections: compare derived vs new entries cell by cell.
   847→        // Include only cells whose entry data differs.
   848→        // This is fully custom — no zstd, no temp files.
   849→        auto write_entry_correction = [&](PatchFileId fid, const std::string& fname,
   850→                                           const std::vector<char>& derived_entries,
   851→                                           const std::vector<char>& derived_cells, size_t cell_stride,
   852→                                           size_t offset_pos) {
   853→            auto new_entries = read_file(new_dir + "/" + fname);
   854→            auto new_cells_data = read_file(new_dir + "/" +
   855→                (fid == PatchFileId::ADMIN_ENTRIES ? "admin_cells.bin" : "geo_cells.bin"));
   856→
   857→            // Parse both derived and new entries by cell
   858→            auto parse_all = [](const std::vector<char>& cells, size_t stride, size_t off_pos,
   859→                                 const std::vector<char>& entries)
   860→                -> std::vector<std::pair<uint64_t, std::vector<uint32_t>>>
   861→            {
   862→                size_t n = cells.size() / stride;
   863→                std::vector<std::pair<uint64_t, std::vector<uint32_t>>> result;
   864→                result.reserve(n);
   865→                for (size_t i = 0; i < n; i++) {
   866→                    uint64_t cid; memcpy(&cid, cells.data() + i * stride, 8);
   867→                    uint32_t off; memcpy(&off, cells.data() + i * stride + off_pos, 4);
   868→                    std::vector<uint32_t> ids;
   869→                    if (off != 0xFFFFFFFF && off + 2 <= entries.size()) {
   870→                        uint16_t count; memcpy(&count, entries.data() + off, 2);
   871→                        if (off + 2 + count * 4 <= entries.size()) {
   872→                            ids.resize(count);
   873→                            memcpy(ids.data(), entries.data() + off + 2, count * 4);
   874→                        }
   875→                    }
   876→                    result.push_back({cid, std::move(ids)});
   877→                }
   878→                return result;
   879→            };
   880→
   881→            auto derived_parsed = parse_all(derived_cells, cell_stride, offset_pos, derived_entries);
   882→            auto new_parsed = parse_all(new_cells_data,
   883→                cell_stride == 12 ? 12 : 20,
   884→                cell_stride == 12 ? 8 : offset_pos,
   885→                new_entries);
   886→
   887→            // Build new cell map for lookup
   888→            std::unordered_map<uint64_t, const std::vector<uint32_t>*> new_map;
   889→            for (auto& [cid, ids] : new_parsed) new_map[cid] = &ids;
   890→
   891→            // Find differing cells
   892→            std::vector<char> corr_data;
   893→            uint32_t diff_count = 0;
   894→            for (auto& [cid, d_ids] : derived_parsed) {
   895→                auto it = new_map.find(cid);
   896→                const std::vector<uint32_t>* n_ids = nullptr;
   897→                if (it != new_map.end()) n_ids = it->second;
   898→
   899→                bool differs = false;
   900→                if (!n_ids) { differs = !d_ids.empty(); }
   901→                else if (d_ids.size() != n_ids->size()) { differs = true; }
   902→                else if (!d_ids.empty() && memcmp(d_ids.data(), n_ids->data(), d_ids.size() * 4) != 0) { differs = true; }
   903→
   904→                if (differs) {
   905→                    wval(corr_data, &cid, 8);
   906→                    uint16_t count = n_ids ? static_cast<uint16_t>(n_ids->size()) : 0;
   907→                    wval(corr_data, &count, 2);
   908→                    if (n_ids && !n_ids->empty())
   909→                        corr_data.insert(corr_data.end(), (const char*)n_ids->data(),
   910→                                        (const char*)n_ids->data() + n_ids->size() * 4);
   911→                    diff_count++;
   912→                }
   913→            }
   914→            // Also check for cells only in new (not in derived)
   915→            std::unordered_set<uint64_t> derived_set;
   916→            for (auto& [cid, _] : derived_parsed) derived_set.insert(cid);
   917→            for (auto& [cid, ids] : new_parsed) {
   918→                if (!derived_set.count(cid) && !ids.empty()) {
   919→                    wval(corr_data, &cid, 8);
   920→                    uint16_t count = static_cast<uint16_t>(ids.size());
   921→                    wval(corr_data, &count, 2);
   922→                    corr_data.insert(corr_data.end(), (const char*)ids.data(),
   923→                                    (const char*)ids.data() + ids.size() * 4);
   924→                    diff_count++;
   925→                }
   926→            }
   927→
   928→            uint32_t marker = ENTRY_CORRECTION_MARKER;
   929→            uint32_t file = static_cast<uint32_t>(fid);
   930→            wval(patch, &marker, 4);
   931→            wval(patch, &file, 4);
   932→            wval(patch, &diff_count, 4);
   933→            patch.insert(patch.end(), corr_data.begin(), corr_data.end());
   934→            std::cerr << "  " << fname << ": " << diff_count << " cell corrections ("
   935→                      << corr_data.size() << " bytes)" << std::endl;
   936→        };
   937→
   938→        // Geo entries: street, addr, interp
   939→        write_entry_correction(PatchFileId::STREET_ENTRIES, "street_entries.bin",
   940→            derived.street_entries_data, derived.geo_cells_data, 20, 8);
   941→        write_entry_correction(PatchFileId::ADDR_ENTRIES, "addr_entries.bin",
   942→            derived.addr_entries_data, derived.geo_cells_data, 20, 12);
   943→        write_entry_correction(PatchFileId::INTERP_ENTRIES, "interp_entries.bin",
   944→            derived.interp_entries_data, derived.geo_cells_data, 20, 16);
   945→        // Admin entries
   946→        write_entry_correction(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin",
   947→            admin_derived.admin_entries_data, admin_derived.admin_cells_data, 12, 8);
   948→
   949→        // Cell flag corrections: identify cells whose has_street/has_addr/has_interp
   950→        // flags differ between old and new. Only ~3K cells typically.
   951→        // The patch tool uses these to correctly assign entry offsets during geo_cells rebuild.
   952→        {
   953→            auto new_geo_data = read_file(new_dir + "/geo_cells.bin");
   954→            std::unordered_map<uint64_t, uint8_t> old_cell_flags;
   955→            for (size_t i = 0; i < old_geo.size() / 20; i++) {
   956→                uint64_t cid; memcpy(&cid, old_geo.data() + i * 20, 8);
   957→                uint32_t s, a, ip;
   958→                memcpy(&s, old_geo.data()+i*20+8, 4); memcpy(&a, old_geo.data()+i*20+12, 4);
   959→                memcpy(&ip, old_geo.data()+i*20+16, 4);
   960→                uint8_t flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
   961→                old_cell_flags[cid] = flags;
   962→            }
   963→            std::vector<std::pair<uint64_t, uint8_t>> flag_corrections;
   964→            for (size_t i = 0; i < new_geo_data.size() / 20; i++) {
   965→                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);
   966→                uint32_t s, a, ip;
   967→                memcpy(&s, new_geo_data.data()+i*20+8, 4); memcpy(&a, new_geo_data.data()+i*20+12, 4);
   968→                memcpy(&ip, new_geo_data.data()+i*20+16, 4);
   969→                uint8_t new_flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
   970→                auto it = old_cell_flags.find(cid);
   971→                uint8_t old_f = (it != old_cell_flags.end()) ? it->second : 0;
   972→                if (new_flags != old_f)
   973→                    flag_corrections.push_back({cid, new_flags});
   974→            }
   975→            uint32_t fm = CELL_FLAGS_MARKER;
   976→            uint32_t fc = static_cast<uint32_t>(flag_corrections.size());
   977→            wval(patch, &fm, 4); wval(patch, &fc, 4);
   978→            for (auto& [cid, flags] : flag_corrections) {
   979→                wval(patch, &cid, 8); wval(patch, &flags, 1);
   980→            }
   981→            std::cerr << "  Cell flag corrections: " << fc << " cells ("
   982→                      << fc * 9 << " bytes)" << std::endl;
   983→        }
   984→    }
   985→
   986→    // End marker
   987→    uint32_t end_marker = 0xFFFFFFFF;
   988→    wval(patch, &end_marker, 4);
   989→
   990→    std::cerr << "\nUncompressed patch: " << patch.size() << " bytes ("
   991→              << patch.size() / 1024 / 1024 << " MiB)" << std::endl;
   992→
   993→    // Compress whole patch with zstd for transport
   994→    {
   995→        std::string raw_path = tmpdir + "/patch.raw";
   996→        write_file(raw_path, patch);
   997→        std::string cmd = "zstd -19 '" + raw_path + "' -o '" + patch_path + "' -f --quiet 2>/dev/null";
   998→        system(cmd.c_str());
   999→        auto compressed = read_file(patch_path);

        auto new_ap = read_file(new_dir + "/admin_polygons.bin");
        auto old_av = read_file(old_dir + "/admin_vertices.bin");
        auto new_av = read_file(new_dir + "/admin_vertices.bin");
        MergeSequence av_seq;
        size_t ap_oi = 0, ap_ni = 0;
        size_t appos = 0;
        while (appos < ap_seq.data.size()) {
            uint8_t op = static_cast<uint8_t>(ap_seq.data[appos]); appos++;
            uint32_t count; memcpy(&count, ap_seq.data.data() + appos, 4); appos += 4;
            if (op == OP_MATCH_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    uint32_t old_vo, vc;
                    memcpy(&old_vo, old_ap.data() + (ap_oi + k) * admin_stride, 4);
                    memcpy(&vc, old_ap.data() + (ap_oi + k) * admin_stride + 4, 4);
                    uint32_t new_vo;
                    memcpy(&new_vo, new_ap.data() + (ap_ni + k) * admin_stride, 4);
                    if (vc > 0) {
                        bool verts_match = (old_vo + vc) * 8 <= old_av.size() &&
                                          (new_vo + vc) * 8 <= new_av.size() &&
                                          memcmp(old_av.data() + old_vo * 8,
                                                 new_av.data() + new_vo * 8, vc * 8) == 0;
                        if (verts_match) {
                            av_seq.add_match(vc);
                        } else {
                            av_seq.add_delete(vc);
                            av_seq.add_insert(new_av.data() + new_vo * 8, vc, 8);
                        }
                    }
                }
                ap_oi += count; ap_ni += count;
            } else if (op == OP_INSERT_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    const char* rec = ap_seq.data.data() + appos + k * admin_stride;
                    uint32_t vo, vc; memcpy(&vo, rec, 4); memcpy(&vc, rec + 4, 4);
                    if (vc > 0 && (vo + vc) * 8 <= new_av.size())
                        av_seq.add_insert(new_av.data() + vo * 8, vc, 8);
                }
                appos += count * admin_stride;
                ap_ni += count;
            } else if (op == OP_DELETE_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    uint32_t vc; memcpy(&vc, old_ap.data() + (ap_oi + k) * admin_stride + 4, 4);
                    if (vc > 0) av_seq.add_delete(vc);
                }
                ap_oi += count;
            }
        }
        uint32_t fid = static_cast<uint32_t>(PatchFileId::ADMIN_VERTICES);
        uint64_t os = old_av.size(), ns = new_av.size(), ss = av_seq.data.size();
        uint32_t st = 8, n_fixups = 0;
        wval(patch, &fid, 4); wval(patch, &st, 4);
        wval(patch, &os, 8); wval(patch, &ns, 8);
        wval(patch, &n_fixups, 4); wval(patch, &ss, 8);
        patch.insert(patch.end(), av_seq.data.begin(), av_seq.data.end());
        std::cerr << "  admin_vertices.bin: parent-derived seq=" << ss << " bytes ("
                  << std::fixed << std::setprecision(2) << (ns > 0 ? ss * 100.0 / ns : 0) << "%)" << std::endl;
    }

    // --- Section: Entry/cell files as full new data ---
    // These are small relative to data files and rebuilt by patch tool isn't reliable.
    // Include them as full replacement. zstd transport compression handles the rest.
    // Entry/cell files: NOT included.
    // The patch tool reconstructs them from the derived ID remaps
    // (extracted implicitly from the merge sequences above).
    // geo_cells.bin, admin_cells.bin, and all entry files are rebuilt.
    std::cerr << "  (entry/cell files: omitted, rebuilt by patch tool)" << std::endl;

    // Include cell_id changes for entry reconstruction
    {
        // Geo cell changes
        auto new_geo = read_file(new_dir + "/geo_cells.bin");
        auto old_geo = read_file(old_dir + "/geo_cells.bin");
        std::unordered_set<uint64_t> old_set, new_set;
        for (size_t i = 0; i < old_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, old_geo.data() + i * 20, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, new_geo.data() + i * 20, 8); new_set.insert(c);
        }
        std::vector<uint64_t> g_added, g_removed;
        for (auto c : new_set) if (!old_set.count(c)) g_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) g_removed.push_back(c);
        std::sort(g_added.begin(), g_added.end());
        std::sort(g_removed.begin(), g_removed.end());

        uint32_t marker = CELL_CHANGES_GEO_MARKER;
        uint32_t na = g_added.size(), nr = g_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : g_added) wval(patch, &c, 8);
        for (auto c : g_removed) wval(patch, &c, 8);
        std::cerr << "  Geo cell changes: +" << na << " -" << nr << " ("
                  << (na + nr) * 8 << " bytes)" << std::endl;

        // Admin cell changes
        auto new_ac = read_file(new_dir + "/admin_cells.bin");
        auto old_ac = read_file(old_dir + "/admin_cells.bin");
        old_set.clear(); new_set.clear();
        for (size_t i = 0; i < old_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, old_ac.data() + i * 12, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, new_ac.data() + i * 12, 8); new_set.insert(c);
        }
        std::vector<uint64_t> a_added, a_removed;
        for (auto c : new_set) if (!old_set.count(c)) a_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) a_removed.push_back(c);
        std::sort(a_added.begin(), a_added.end());
        std::sort(a_removed.begin(), a_removed.end());

        marker = CELL_CHANGES_ADMIN_MARKER;
        na = a_added.size(); nr = a_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : a_added) wval(patch, &c, 8);
        for (auto c : a_removed) wval(patch, &c, 8);
        std::cerr << "  Admin cell changes: +" << na << " -" << nr << std::endl;

        // Derive ID remaps from stored merge sequences (same as patch tool does)
        auto derive_remap = [&](PatchFileId id, size_t old_size, size_t stride) -> std::unordered_map<uint32_t,uint32_t> {
            auto it = stored_merges.find(static_cast<uint32_t>(id));
            if (it == stored_merges.end()) return {};
            auto vec = derive_id_remap_from_merge(it->second, old_size / stride, stride);
            std::unordered_map<uint32_t,uint32_t> m;
            for (uint32_t i = 0; i < vec.size(); i++)
                if (vec[i] != 0xFFFFFFFF) m[i] = vec[i];
            return m;
        };

        auto w_rm_d = derive_remap(PatchFileId::STREET_WAYS,
            read_file(old_dir + "/street_ways.bin").size(), way_stride);
        auto a_rm_d = derive_remap(PatchFileId::ADDR_POINTS,
            read_file(old_dir + "/addr_points.bin").size(), 16);
        auto i_rm_d = derive_remap(PatchFileId::INTERP_WAYS,
            read_file(old_dir + "/interp_ways.bin").size(), interp_stride);
        auto ad_rm_d = derive_remap(PatchFileId::ADMIN_POLYGONS,
            read_file(old_dir + "/admin_polygons.bin").size(), admin_stride);

        // Rebuild entries using derived remaps (same as patch tool)
        auto old_se2 = read_file(old_dir + "/street_entries.bin");
        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");
        auto derived = rebuild_geo_from_remap(old_geo, old_se2, old_ae2, old_ie2,
                                               w_rm_d, a_rm_d, i_rm_d, g_added, g_removed);
        auto old_admc = read_file(old_dir + "/admin_cells.bin");
        auto old_adme = read_file(old_dir + "/admin_entries.bin");
        auto admin_derived = rebuild_admin_from_remap(old_admc, old_adme, ad_rm_d, a_added, a_removed);

        // Cell-level entry corrections: compare derived vs new entries cell by cell.
        // Include only cells whose entry data differs.
        // This is fully custom — no zstd, no temp files.
        auto write_entry_correction = [&](PatchFileId fid, const std::string& fname,
                                           const std::vector<char>& derived_entries,
                                           const std::vector<char>& derived_cells, size_t cell_stride,
                                           size_t offset_pos) {
            auto new_entries = read_file(new_dir + "/" + fname);
            auto new_cells_data = read_file(new_dir + "/" +
                (fid == PatchFileId::ADMIN_ENTRIES ? "admin_cells.bin" : "geo_cells.bin"));

            // Parse both derived and new entries by cell
            auto parse_all = [](const std::vector<char>& cells, size_t stride, size_t off_pos,
                                 const std::vector<char>& entries)
                -> std::vector<std::pair<uint64_t, std::vector<uint32_t>>>
            {
                size_t n = cells.size() / stride;
                std::vector<std::pair<uint64_t, std::vector<uint32_t>>> result;
                result.reserve(n);
                for (size_t i = 0; i < n; i++) {
                    uint64_t cid; memcpy(&cid, cells.data() + i * stride, 8);
                    uint32_t off; memcpy(&off, cells.data() + i * stride + off_pos, 4);
                    std::vector<uint32_t> ids;
                    if (off != 0xFFFFFFFF && off + 2 <= entries.size()) {
                        uint16_t count; memcpy(&count, entries.data() + off, 2);
                        if (off + 2 + count * 4 <= entries.size()) {
                            ids.resize(count);
                            memcpy(ids.data(), entries.data() + off + 2, count * 4);
                        }
                    }
                    result.push_back({cid, std::move(ids)});
                }
                return result;
            };

            auto derived_parsed = parse_all(derived_cells, cell_stride, offset_pos, derived_entries);
            auto new_parsed = parse_all(new_cells_data,
                cell_stride == 12 ? 12 : 20,
                cell_stride == 12 ? 8 : offset_pos,
                new_entries);

            // Build new cell map for lookup
            std::unordered_map<uint64_t, const std::vector<uint32_t>*> new_map;
            for (auto& [cid, ids] : new_parsed) new_map[cid] = &ids;

            // Find differing cells
            std::vector<char> corr_data;
            uint32_t diff_count = 0;
            for (auto& [cid, d_ids] : derived_parsed) {
                auto it = new_map.find(cid);
                const std::vector<uint32_t>* n_ids = nullptr;
                if (it != new_map.end()) n_ids = it->second;

                bool differs = false;
                if (!n_ids) { differs = !d_ids.empty(); }
                else if (d_ids.size() != n_ids->size()) { differs = true; }
                else if (!d_ids.empty() && memcmp(d_ids.data(), n_ids->data(), d_ids.size() * 4) != 0) { differs = true; }

                if (differs) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = n_ids ? static_cast<uint16_t>(n_ids->size()) : 0;
                    wval(corr_data, &count, 2);
                    if (n_ids && !n_ids->empty())
                        corr_data.insert(corr_data.end(), (const char*)n_ids->data(),
                                        (const char*)n_ids->data() + n_ids->size() * 4);
                    diff_count++;
                }
            }
            // Also check for cells only in new (not in derived)
            std::unordered_set<uint64_t> derived_set;
            for (auto& [cid, _] : derived_parsed) derived_set.insert(cid);
            for (auto& [cid, ids] : new_parsed) {
                if (!derived_set.count(cid) && !ids.empty()) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = static_cast<uint16_t>(ids.size());
                    wval(corr_data, &count, 2);
                    corr_data.insert(corr_data.end(), (const char*)ids.data(),
                                    (const char*)ids.data() + ids.size() * 4);
                    diff_count++;
                }
            }

            uint32_t marker = ENTRY_CORRECTION_MARKER;
            uint32_t file = static_cast<uint32_t>(fid);
            wval(patch, &marker, 4);
            wval(patch, &file, 4);
            wval(patch, &diff_count, 4);
            patch.insert(patch.end(), corr_data.begin(), corr_data.end());
            std::cerr << "  " << fname << ": " << diff_count << " cell corrections ("
                      << corr_data.size() << " bytes)" << std::endl;
        };

        // Geo entries: street, addr, interp
        write_entry_correction(PatchFileId::STREET_ENTRIES, "street_entries.bin",
            derived.street_entries_data, derived.geo_cells_data, 20, 8);
        write_entry_correction(PatchFileId::ADDR_ENTRIES, "addr_entries.bin",
            derived.addr_entries_data, derived.geo_cells_data, 20, 12);
        write_entry_correction(PatchFileId::INTERP_ENTRIES, "interp_entries.bin",
            derived.interp_entries_data, derived.geo_cells_data, 20, 16);
        // Admin entries
        write_entry_correction(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin",
            admin_derived.admin_entries_data, admin_derived.admin_cells_data, 12, 8);

        // Cell flag corrections: identify cells whose has_street/has_addr/has_interp
        // flags differ between old and new. Only ~3K cells typically.
        // The patch tool uses these to correctly assign entry offsets during geo_cells rebuild.
        {
            auto new_geo_data = read_file(new_dir + "/geo_cells.bin");
            std::unordered_map<uint64_t, uint8_t> old_cell_flags;
            for (size_t i = 0; i < old_geo.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, old_geo.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, old_geo.data()+i*20+8, 4); memcpy(&a, old_geo.data()+i*20+12, 4);
                memcpy(&ip, old_geo.data()+i*20+16, 4);
                uint8_t flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                old_cell_flags[cid] = flags;
            }
            std::vector<std::pair<uint64_t, uint8_t>> flag_corrections;
            for (size_t i = 0; i < new_geo_data.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, new_geo_data.data()+i*20+8, 4); memcpy(&a, new_geo_data.data()+i*20+12, 4);
                memcpy(&ip, new_geo_data.data()+i*20+16, 4);
                uint8_t new_flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                auto it = old_cell_flags.find(cid);
                uint8_t old_f = (it != old_cell_flags.end()) ? it->second : 0;
                if (new_flags != old_f)
                    flag_corrections.push_back({cid, new_flags});
            }
            uint32_t fm = CELL_FLAGS_MARKER;
            uint32_t fc = static_cast<uint32_t>(flag_corrections.size());
            wval(patch, &fm, 4); wval(patch, &fc, 4);
            for (auto& [cid, flags] : flag_corrections) {
                wval(patch, &cid, 8); wval(patch, &flags, 1);
            }
            std::cerr << "  Cell flag corrections: " << fc << " cells ("
                      << fc * 9 << " bytes)" << std::endl;
        }
    }

    // End marker
    uint32_t end_marker = 0xFFFFFFFF;
    wval(patch, &end_marker, 4);

    std::cerr << "\nUncompressed patch: " << patch.size() << " bytes ("
              << patch.size() / 1024 / 1024 << " MiB)" << std::endl;

    // Compress whole patch with zstd for transport
    {
        std::string raw_path = tmpdir + "/patch.raw";
        write_file(raw_path, patch);
        std::string cmd = "zstd -19 '" + raw_path + "' -o '" + patch_path + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());
        auto compressed = read_file(patch_path);


   999→        auto compressed = read_file(patch_path);
  1000→        std::cerr << "Compressed patch: " << compressed.size() << " bytes ("
  1001→                  << compressed.size() / 1024 / 1024 << " MiB)" << std::endl;
  1002→        remove(raw_path.c_str());
  1003→    }
  1004→
  1005→    std::string rm_cmd = "rm -rf '" + tmpdir + "'";
  1006→    system(rm_cmd.c_str());
  1007→    return 0;
  1008→}
  1009→

        auto compressed = read_file(patch_path);
        std::cerr << "Compressed patch: " << compressed.size() << " bytes ("
                  << compressed.size() / 1024 / 1024 << " MiB)" << std::endl;
        remove(raw_path.c_str());
    }

    std::string rm_cmd = "rm -rf '" + tmpdir + "'";
    system(rm_cmd.c_str());
    return 0;
}


Now I have a clear picture of the code. The 2M street cell corrections come from ~173K unmatched ways — ways that changed geometry but represent the same logical entity. The current hash-based fixup matches by (name + coordinates), so geometry-changed ways fail to match. Their old→new ID mapping is lost, cascading to 2M cell corrections.

The fix: after building the merge sequence, do a secondary "soft match" of DELETE/INSERT records by name_id only. This captures geometry-edited ways and adds them to the ID remap.

Let me first add diagnostics and implement this optimization.


     1→#pragma once
     2→// Shared definitions for the geocoder patch system.
     3→// Used by geocoder-canonicalize, geocoder-diff, and geocoder-patch.
     4→
     5→#include <algorithm>
     6→#include <cstdint>
     7→#include <cstring>
     8→#include <fstream>
     9→#include <iostream>
    10→#include <string>
    11→#include <unordered_set>
    12→#include <vector>
    13→
    14→// --- Binary record structs (must match types.h and server) ---
    15→
    16→#pragma pack(push, 1)
    17→struct PatchWayHeader {
    18→    uint32_t node_offset;
    19→    uint8_t node_count;
    20→    uint32_t name_id;
    21→};
    22→static_assert(sizeof(PatchWayHeader) == 9, "WayHeader must be 9 bytes packed");
    23→
    24→struct PatchAddrPoint {
    25→    float lat;
    26→    float lng;
    27→    uint32_t housenumber_id;
    28→    uint32_t street_id;
    29→};
    30→static_assert(sizeof(PatchAddrPoint) == 16, "AddrPoint must be 16 bytes");
    31→
    32→struct PatchNodeCoord {
    33→    float lat;
    34→    float lng;
    35→};
    36→static_assert(sizeof(PatchNodeCoord) == 8, "NodeCoord must be 8 bytes");
    37→#pragma pack(pop)
    38→
    39→// InterpWay and AdminPolygon have compiler-dependent padding.
    40→// Read them field-by-field instead of struct-casting.
    41→
    42→struct PatchInterpWay {
    43→    uint32_t node_offset;
    44→    uint8_t node_count;
    45→    uint32_t street_id;
    46→    uint32_t start_number;
    47→    uint32_t end_number;
    48→    uint8_t interpolation;
    49→};
    50→
    51→struct PatchAdminPolygon {
    52→    uint32_t vertex_offset;
    53→    uint32_t vertex_count;
    54→    uint32_t name_id;
    55→    uint8_t admin_level;
    56→    float area;
    57→    uint16_t country_code;
    58→};
    59→
    60→// --- File I/O helpers ---
    61→
    62→inline std::vector<char> read_file(const std::string& path) {
    63→    std::ifstream f(path, std::ios::binary | std::ios::ate);
    64→    if (!f) return {};
    65→    auto size = f.tellg();
    66→    f.seekg(0);
    67→    std::vector<char> data(size);
    68→    f.read(data.data(), size);
    69→    return data;
    70→}
    71→
    72→inline bool write_file(const std::string& path, const char* data, size_t size) {
    73→    std::ofstream f(path, std::ios::binary);
    74→    if (!f) return false;
    75→    f.write(data, size);
    76→    return f.good();
    77→}
    78→
    79→inline bool write_file(const std::string& path, const std::vector<char>& data) {
    80→    return write_file(path, data.data(), data.size());
    81→}
    82→
    83→inline const char* get_string(const std::vector<char>& pool, uint32_t offset) {
    84→    if (offset >= pool.size()) return "";
    85→    return pool.data() + offset;
    86→}
    87→
    88→// Read WayHeader array — detect stride from file size vs known struct sizes
    89→inline std::vector<PatchWayHeader> read_ways(const std::string& path) {
    90→    auto data = read_file(path);
    91→    // Determine stride: builder writes sizeof(WayHeader) which may be 9 or 12
    92→    // depending on compiler padding. Try 12 first (common), then 9.
    93→    size_t stride = 12;
    94→    if (data.size() % 12 != 0 || data.size() / 12 == 0) {
    95→        stride = 9;
    96→    }
    97→    size_t count = data.size() / stride;
    98→    std::vector<PatchWayHeader> result(count);
    99→    for (size_t i = 0; i < count; i++) {
   100→        const char* p = data.data() + i * stride;

#pragma once
// Shared definitions for the geocoder patch system.
// Used by geocoder-canonicalize, geocoder-diff, and geocoder-patch.

#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iostream>
#include <string>
#include <unordered_set>
#include <vector>

// --- Binary record structs (must match types.h and server) ---

#pragma pack(push, 1)
struct PatchWayHeader {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t name_id;
};
static_assert(sizeof(PatchWayHeader) == 9, "WayHeader must be 9 bytes packed");

struct PatchAddrPoint {
    float lat;
    float lng;
    uint32_t housenumber_id;
    uint32_t street_id;
};
static_assert(sizeof(PatchAddrPoint) == 16, "AddrPoint must be 16 bytes");

struct PatchNodeCoord {
    float lat;
    float lng;
};
static_assert(sizeof(PatchNodeCoord) == 8, "NodeCoord must be 8 bytes");
#pragma pack(pop)

// InterpWay and AdminPolygon have compiler-dependent padding.
// Read them field-by-field instead of struct-casting.

struct PatchInterpWay {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t street_id;
    uint32_t start_number;
    uint32_t end_number;
    uint8_t interpolation;
};

struct PatchAdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    float area;
    uint16_t country_code;
};

// --- File I/O helpers ---

inline std::vector<char> read_file(const std::string& path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};
    auto size = f.tellg();
    f.seekg(0);
    std::vector<char> data(size);
    f.read(data.data(), size);
    return data;
}

inline bool write_file(const std::string& path, const char* data, size_t size) {
    std::ofstream f(path, std::ios::binary);
    if (!f) return false;
    f.write(data, size);
    return f.good();
}

inline bool write_file(const std::string& path, const std::vector<char>& data) {
    return write_file(path, data.data(), data.size());
}

inline const char* get_string(const std::vector<char>& pool, uint32_t offset) {
    if (offset >= pool.size()) return "";
    return pool.data() + offset;
}

// Read WayHeader array — detect stride from file size vs known struct sizes
inline std::vector<PatchWayHeader> read_ways(const std::string& path) {
    auto data = read_file(path);
    // Determine stride: builder writes sizeof(WayHeader) which may be 9 or 12
    // depending on compiler padding. Try 12 first (common), then 9.
    size_t stride = 12;
    if (data.size() % 12 != 0 || data.size() / 12 == 0) {
        stride = 9;
    }
    size_t count = data.size() / stride;
    std::vector<PatchWayHeader> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;


   100→        const char* p = data.data() + i * stride;
   101→        memcpy(&result[i].node_offset, p, 4);
   102→        result[i].node_count = static_cast<uint8_t>(p[4]);
   103→        // name_id is at offset 5 (packed) or 8 (padded to 4-byte alignment)
   104→        size_t name_off = (stride == 12) ? 8 : 5;
   105→        memcpy(&result[i].name_id, p + name_off, 4);
   106→    }
   107→    return result;
   108→}
   109→
   110→inline size_t detect_way_stride(const std::string& path) {
   111→    auto data = read_file(path);
   112→    return (data.size() % 12 == 0 && data.size() / 12 > 0) ? 12 : 9;
   113→}
   114→
   115→template<typename T>
   116→inline std::vector<T> read_structs(const std::string& path) {
   117→    auto data = read_file(path);
   118→    size_t count = data.size() / sizeof(T);
   119→    std::vector<T> result(count);
   120→    if (count > 0) memcpy(result.data(), data.data(), count * sizeof(T));
   121→    return result;
   122→}
   123→
   124→// Read InterpWay with field-by-field parsing to handle padding
   125→inline std::vector<PatchInterpWay> read_interps(const std::string& path) {
   126→    auto data = read_file(path);
   127→    // Determine record size from file: try sizeof(InterpWay) as compiled by the builder
   128→    // The builder uses the same struct, so sizeof should match.
   129→    size_t rec_size = sizeof(PatchInterpWay);
   130→    // But the builder's InterpWay has padding. Read with the builder's size.
   131→    // From types.h the struct is: u32 + u8 + u32 + u32 + u32 + u8 = 18 bytes raw
   132→    // but compiler pads to 20 bytes typically. Check by trying both.
   133→    size_t count_20 = data.size() / 20;
   134→    size_t count_18 = data.size() / 18;
   135→    // Use whichever divides evenly
   136→    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
   137→                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
   138→    size_t count = data.size() / stride;
   139→    std::vector<PatchInterpWay> result(count);
   140→    for (size_t i = 0; i < count; i++) {
   141→        const char* p = data.data() + i * stride;
   142→        memcpy(&result[i].node_offset, p, 4); p += 4;
   143→        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
   144→        // Skip padding to align street_id
   145→        if (stride == 20) p += 3; // 3 bytes padding after node_count
   146→        memcpy(&result[i].street_id, p, 4); p += 4;
   147→        memcpy(&result[i].start_number, p, 4); p += 4;
   148→        memcpy(&result[i].end_number, p, 4); p += 4;
   149→        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
   150→    }
   151→    return result;
   152→}
   153→
   154→// Read AdminPolygon with field-by-field parsing
   155→inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {
   156→    auto data = read_file(path);
   157→    // AdminPolygon: u32 + u32 + u32 + u8 + f32 + u16 = 19 raw bytes
   158→    // Compiler likely pads to 20 or 24 bytes. Try common sizes.
   159→    size_t stride = 0;
   160→    for (size_t s : {24, 20, 19}) {
   161→        if (data.size() % s == 0 && data.size() / s > 0) { stride = s; break; }
   162→    }
   163→    if (stride == 0) stride = 24; // fallback
   164→    size_t count = data.size() / stride;
   165→    std::vector<PatchAdminPolygon> result(count);
   166→    for (size_t i = 0; i < count; i++) {
   167→        const char* p = data.data() + i * stride;
   168→        memcpy(&result[i].vertex_offset, p, 4); p += 4;
   169→        memcpy(&result[i].vertex_count, p, 4); p += 4;
   170→        memcpy(&result[i].name_id, p, 4); p += 4;
   171→        result[i].admin_level = *reinterpret_cast<const uint8_t*>(p); p += 1;
   172→        // padding for area alignment
   173→        if (stride >= 20) {
   174→            size_t skip = (stride == 24) ? 3 : (stride == 20 ? 3 : 0);
   175→            p += skip;
   176→        }
   177→        memcpy(&result[i].area, p, 4); p += 4;
   178→        memcpy(&result[i].country_code, p, 2);
   179→    }
   180→    return result;
   181→}
   182→
   183→// --- Patch file format ---
   184→
   185→static constexpr char GCPATCH_MAGIC[8] = {'G','C','P','A','T','C','H','\0'};
   186→static constexpr uint32_t GCPATCH_VERSION = 1;
   187→
   188→enum class PatchFileId : uint32_t {
   189→    STRINGS = 0,
   190→    STREET_WAYS = 1,
   191→    STREET_NODES = 2,
   192→    ADDR_POINTS = 3,
   193→    INTERP_WAYS = 4,
   194→    INTERP_NODES = 5,
   195→    ADMIN_POLYGONS = 6,
   196→    ADMIN_VERTICES = 7,
   197→    GEO_CELLS = 8,
   198→    STREET_ENTRIES = 9,
   199→    ADDR_ENTRIES = 10,
   200→    INTERP_ENTRIES = 11,
   201→    ADMIN_CELLS = 12,
   202→    ADMIN_ENTRIES = 13,
   203→    COUNT = 14
   204→};
   205→
   206→static const char* patch_file_names[] = {
   207→    "strings.bin", "street_ways.bin", "street_nodes.bin", "addr_points.bin",
   208→    "interp_ways.bin", "interp_nodes.bin", "admin_polygons.bin", "admin_vertices.bin",
   209→    "geo_cells.bin", "street_entries.bin", "addr_entries.bin", "interp_entries.bin",
   210→    "admin_cells.bin", "admin_entries.bin"
   211→};
   212→
   213→// Encoding types for each section
   214→enum class PatchEncoding : uint32_t {
   215→    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
   216→    ZSTD_DELTA = 1,   // Single zstd delta frame (zstd --patch-from on remapped old)
   217→};
   218→
   219→// Patch file header
   220→struct PatchHeader {
   221→    char magic[8];
   222→    uint32_t version;
   223→    uint32_t flags;
   224→    // Per-file sections follow
   225→};
   226→
   227→// Section header (one per file)
   228→struct PatchSection {
   229→    uint32_t file_id;       // PatchFileId
   230→    uint32_t encoding;      // PatchEncoding
   231→    uint64_t old_size;      // Expected size of old file
   232→    uint64_t new_size;      // Size of reconstructed file
   233→    uint64_t data_size;     // Size of compressed section data
   234→};
   235→
   236→// COPY_INSERT opcodes
   237→enum : uint8_t {
   238→    OP_COPY = 0,    // Copy from old file: [u8 tag][u64 offset][u32 length]
   239→    OP_INSERT = 1,  // Insert new data:    [u8 tag][u32 length][data...]
   240→};
   241→
   242→// Offset fixup section marker: 0xFFFFFFFD
   243→// Format: uint32_t marker, uint32_t file_id, uint32_t stride,
   244→//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count
   245→// Applied to byte offset 0 of each record (node_offset/vertex_offset field).
   246→
   247→static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;
   248→
   249→// Cell changes section marker: 0xFFFFFFFB
   250→// Format: marker, num_added(u32), num_removed(u32),
   251→//         [added_cell_id(u64)] * num_added, [removed_cell_id(u64)] * num_removed
   252→// For both geo and admin cell sets.
   253→static constexpr uint32_t CELL_CHANGES_GEO_MARKER = 0xFFFFFFFB;
   254→static constexpr uint32_t CELL_CHANGES_ADMIN_MARKER = 0xFFFFFFFA;
   255→
   256→// Entry correction marker: 0xFFFFFFF8
   257→// Cell-level diff of entries: lists cells whose entries differ between derived and new.
   258→// Format: marker(4), file_id(4), count(4),
   259→//   for each: cell_index(4), entry_count(2), [id(4)] * entry_count
   260→// cell_index is the position in the geo_cells/admin_cells array.
   261→static constexpr uint32_t ENTRY_CORRECTION_MARKER = 0xFFFFFFF8;
   262→
   263→// Cell flag corrections marker: 0xFFFFFFF9
   264→// Format: marker, count(u32), [(cell_id:u64, flags:u8)] × count
   265→// flags: bit 0 = has_street, bit 1 = has_addr, bit 2 = has_interp
   266→static constexpr uint32_t CELL_FLAGS_MARKER = 0xFFFFFFF9;
   267→
   268→// --- Shared entry rebuild logic ---
   269→// Used by both diff and patch tools to produce identical rebuilt entries.
   270→// Takes old geo_cells + old entries + ID remap → produces rebuilt entries + geo_cells.
   271→
   272→struct RebuiltGeo {
   273→    std::vector<char> geo_cells_data;
   274→    std::vector<char> street_entries_data;
   275→    std::vector<char> addr_entries_data;
   276→    std::vector<char> interp_entries_data;
   277→};
   278→
   279→inline RebuiltGeo rebuild_geo_from_remap(
   280→    const std::vector<char>& old_geo,
   281→    const std::vector<char>& old_se, const std::vector<char>& old_ae, const std::vector<char>& old_ie,
   282→    const std::unordered_map<uint32_t,uint32_t>& way_rm,
   283→    const std::unordered_map<uint32_t,uint32_t>& addr_rm,
   284→    const std::unordered_map<uint32_t,uint32_t>& interp_rm,
   285→    const std::vector<uint64_t>& added_cells = {},
   286→    const std::vector<uint64_t>& removed_cells = {})
   287→{
   288→    size_t n_cells = old_geo.size() / 20;
   289→
   290→    auto parse_entry = [](const std::vector<char>& data, uint32_t off) -> std::vector<uint32_t> {
   291→        if (off == 0xFFFFFFFF || off + 2 > data.size()) return {};
   292→        uint16_t count; memcpy(&count, data.data() + off, 2);
   293→        if (off + 2 + count * 4 > data.size()) return {};
   294→        std::vector<uint32_t> ids(count);
   295→        memcpy(ids.data(), data.data() + off + 2, count * 4);
   296→        return ids;
   297→    };
   298→
   299→    auto remap_ids = [](std::vector<uint32_t>& ids, const std::unordered_map<uint32_t,uint32_t>& rm) {

        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4);
        result[i].node_count = static_cast<uint8_t>(p[4]);
        // name_id is at offset 5 (packed) or 8 (padded to 4-byte alignment)
        size_t name_off = (stride == 12) ? 8 : 5;
        memcpy(&result[i].name_id, p + name_off, 4);
    }
    return result;
}

inline size_t detect_way_stride(const std::string& path) {
    auto data = read_file(path);
    return (data.size() % 12 == 0 && data.size() / 12 > 0) ? 12 : 9;
}

template<typename T>
inline std::vector<T> read_structs(const std::string& path) {
    auto data = read_file(path);
    size_t count = data.size() / sizeof(T);
    std::vector<T> result(count);
    if (count > 0) memcpy(result.data(), data.data(), count * sizeof(T));
    return result;
}

// Read InterpWay with field-by-field parsing to handle padding
inline std::vector<PatchInterpWay> read_interps(const std::string& path) {
    auto data = read_file(path);
    // Determine record size from file: try sizeof(InterpWay) as compiled by the builder
    // The builder uses the same struct, so sizeof should match.
    size_t rec_size = sizeof(PatchInterpWay);
    // But the builder's InterpWay has padding. Read with the builder's size.
    // From types.h the struct is: u32 + u8 + u32 + u32 + u32 + u8 = 18 bytes raw
    // but compiler pads to 20 bytes typically. Check by trying both.
    size_t count_20 = data.size() / 20;
    size_t count_18 = data.size() / 18;
    // Use whichever divides evenly
    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
    size_t count = data.size() / stride;
    std::vector<PatchInterpWay> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4); p += 4;
        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // Skip padding to align street_id
        if (stride == 20) p += 3; // 3 bytes padding after node_count
        memcpy(&result[i].street_id, p, 4); p += 4;
        memcpy(&result[i].start_number, p, 4); p += 4;
        memcpy(&result[i].end_number, p, 4); p += 4;
        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
    }
    return result;
}

// Read AdminPolygon with field-by-field parsing
inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {
    auto data = read_file(path);
    // AdminPolygon: u32 + u32 + u32 + u8 + f32 + u16 = 19 raw bytes
    // Compiler likely pads to 20 or 24 bytes. Try common sizes.
    size_t stride = 0;
    for (size_t s : {24, 20, 19}) {
        if (data.size() % s == 0 && data.size() / s > 0) { stride = s; break; }
    }
    if (stride == 0) stride = 24; // fallback
    size_t count = data.size() / stride;
    std::vector<PatchAdminPolygon> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].vertex_offset, p, 4); p += 4;
        memcpy(&result[i].vertex_count, p, 4); p += 4;
        memcpy(&result[i].name_id, p, 4); p += 4;
        result[i].admin_level = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // padding for area alignment
        if (stride >= 20) {
            size_t skip = (stride == 24) ? 3 : (stride == 20 ? 3 : 0);
            p += skip;
        }
        memcpy(&result[i].area, p, 4); p += 4;
        memcpy(&result[i].country_code, p, 2);
    }
    return result;
}

// --- Patch file format ---

static constexpr char GCPATCH_MAGIC[8] = {'G','C','P','A','T','C','H','\0'};
static constexpr uint32_t GCPATCH_VERSION = 1;

enum class PatchFileId : uint32_t {
    STRINGS = 0,
    STREET_WAYS = 1,
    STREET_NODES = 2,
    ADDR_POINTS = 3,
    INTERP_WAYS = 4,
    INTERP_NODES = 5,
    ADMIN_POLYGONS = 6,
    ADMIN_VERTICES = 7,
    GEO_CELLS = 8,
    STREET_ENTRIES = 9,
    ADDR_ENTRIES = 10,
    INTERP_ENTRIES = 11,
    ADMIN_CELLS = 12,
    ADMIN_ENTRIES = 13,
    COUNT = 14
};

static const char* patch_file_names[] = {
    "strings.bin", "street_ways.bin", "street_nodes.bin", "addr_points.bin",
    "interp_ways.bin", "interp_nodes.bin", "admin_polygons.bin", "admin_vertices.bin",
    "geo_cells.bin", "street_entries.bin", "addr_entries.bin", "interp_entries.bin",
    "admin_cells.bin", "admin_entries.bin"
};

// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    ZSTD_DELTA = 1,   // Single zstd delta frame (zstd --patch-from on remapped old)
};

// Patch file header
struct PatchHeader {
    char magic[8];
    uint32_t version;
    uint32_t flags;
    // Per-file sections follow
};

// Section header (one per file)
struct PatchSection {
    uint32_t file_id;       // PatchFileId
    uint32_t encoding;      // PatchEncoding
    uint64_t old_size;      // Expected size of old file
    uint64_t new_size;      // Size of reconstructed file
    uint64_t data_size;     // Size of compressed section data
};

// COPY_INSERT opcodes
enum : uint8_t {
    OP_COPY = 0,    // Copy from old file: [u8 tag][u64 offset][u32 length]
    OP_INSERT = 1,  // Insert new data:    [u8 tag][u32 length][data...]
};

// Offset fixup section marker: 0xFFFFFFFD
// Format: uint32_t marker, uint32_t file_id, uint32_t stride,
//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count
// Applied to byte offset 0 of each record (node_offset/vertex_offset field).

static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;

// Cell changes section marker: 0xFFFFFFFB
// Format: marker, num_added(u32), num_removed(u32),
//         [added_cell_id(u64)] * num_added, [removed_cell_id(u64)] * num_removed
// For both geo and admin cell sets.
static constexpr uint32_t CELL_CHANGES_GEO_MARKER = 0xFFFFFFFB;
static constexpr uint32_t CELL_CHANGES_ADMIN_MARKER = 0xFFFFFFFA;

// Entry correction marker: 0xFFFFFFF8
// Cell-level diff of entries: lists cells whose entries differ between derived and new.
// Format: marker(4), file_id(4), count(4),
//   for each: cell_index(4), entry_count(2), [id(4)] * entry_count
// cell_index is the position in the geo_cells/admin_cells array.
static constexpr uint32_t ENTRY_CORRECTION_MARKER = 0xFFFFFFF8;

// Cell flag corrections marker: 0xFFFFFFF9
// Format: marker, count(u32), [(cell_id:u64, flags:u8)] × count
// flags: bit 0 = has_street, bit 1 = has_addr, bit 2 = has_interp
static constexpr uint32_t CELL_FLAGS_MARKER = 0xFFFFFFF9;

// --- Shared entry rebuild logic ---
// Used by both diff and patch tools to produce identical rebuilt entries.
// Takes old geo_cells + old entries + ID remap → produces rebuilt entries + geo_cells.

struct RebuiltGeo {
    std::vector<char> geo_cells_data;
    std::vector<char> street_entries_data;
    std::vector<char> addr_entries_data;
    std::vector<char> interp_entries_data;
};

inline RebuiltGeo rebuild_geo_from_remap(
    const std::vector<char>& old_geo,
    const std::vector<char>& old_se, const std::vector<char>& old_ae, const std::vector<char>& old_ie,
    const std::unordered_map<uint32_t,uint32_t>& way_rm,
    const std::unordered_map<uint32_t,uint32_t>& addr_rm,
    const std::unordered_map<uint32_t,uint32_t>& interp_rm,
    const std::vector<uint64_t>& added_cells = {},
    const std::vector<uint64_t>& removed_cells = {})
{
    size_t n_cells = old_geo.size() / 20;

    auto parse_entry = [](const std::vector<char>& data, uint32_t off) -> std::vector<uint32_t> {
        if (off == 0xFFFFFFFF || off + 2 > data.size()) return {};
        uint16_t count; memcpy(&count, data.data() + off, 2);
        if (off + 2 + count * 4 > data.size()) return {};
        std::vector<uint32_t> ids(count);
        memcpy(ids.data(), data.data() + off + 2, count * 4);
        return ids;
    };

    auto remap_ids = [](std::vector<uint32_t>& ids, const std::unordered_map<uint32_t,uint32_t>& rm) {


   299→    auto remap_ids = [](std::vector<uint32_t>& ids, const std::unordered_map<uint32_t,uint32_t>& rm) {
   300→        for (auto& id : ids) {
   301→            auto it = rm.find(id);
   302→            if (it != rm.end()) id = it->second;
   303→        }
   304→        std::sort(ids.begin(), ids.end());
   305→    };
   306→
   307→    // Parse all cells and remap IDs
   308→    struct CellData {
   309→        uint64_t cell_id;
   310→        std::vector<uint32_t> streets, addrs, interps;
   311→    };
   312→    std::vector<CellData> cells(n_cells);
   313→    for (size_t i = 0; i < n_cells; i++) {
   314→        memcpy(&cells[i].cell_id, old_geo.data() + i * 20, 8);
   315→        uint32_t s_off, a_off, i_off;
   316→        memcpy(&s_off, old_geo.data() + i * 20 + 8, 4);
   317→        memcpy(&a_off, old_geo.data() + i * 20 + 12, 4);
   318→        memcpy(&i_off, old_geo.data() + i * 20 + 16, 4);
   319→        cells[i].streets = parse_entry(old_se, s_off);
   320→        cells[i].addrs = parse_entry(old_ae, a_off);
   321→        cells[i].interps = parse_entry(old_ie, i_off);
   322→        remap_ids(cells[i].streets, way_rm);
   323→        remap_ids(cells[i].addrs, addr_rm);
   324→        remap_ids(cells[i].interps, interp_rm);
   325→    }
   326→
   327→    // Apply cell set changes (add new cells, remove old cells)
   328→    if (!removed_cells.empty()) {
   329→        std::unordered_set<uint64_t> removed_set(removed_cells.begin(), removed_cells.end());
   330→        cells.erase(std::remove_if(cells.begin(), cells.end(),
   331→            [&](const CellData& c) { return removed_set.count(c.cell_id); }), cells.end());
   332→    }
   333→    if (!added_cells.empty()) {
   334→        for (uint64_t cid : added_cells) {
   335→            CellData cd; cd.cell_id = cid;
   336→            // New cell entry data is provided via the new_cell_entries parameter (if available)
   337→            cells.push_back(cd);
   338→        }
   339→        // Re-sort to maintain cell_id order
   340→        std::sort(cells.begin(), cells.end(),
   341→            [](const CellData& a, const CellData& b) { return a.cell_id < b.cell_id; });
   342→    }
   343→
   344→    // Write rebuilt files
   345→    RebuiltGeo result;
   346→    uint32_t no_data = 0xFFFFFFFF;
   347→
   348→    auto write_entries = [&](std::vector<char>& buf, const auto& getter) -> std::unordered_map<uint64_t, uint32_t> {
   349→        std::unordered_map<uint64_t, uint32_t> offsets;
   350→        for (auto& c : cells) {
   351→            const auto& ids = getter(c);
   352→            if (ids.empty()) continue;
   353→            offsets[c.cell_id] = static_cast<uint32_t>(buf.size());
   354→            uint16_t count = static_cast<uint16_t>(ids.size());
   355→            buf.insert(buf.end(), (const char*)&count, (const char*)&count + 2);
   356→            buf.insert(buf.end(), (const char*)ids.data(), (const char*)ids.data() + ids.size() * 4);
   357→        }
   358→        return offsets;
   359→    };
   360→
   361→    auto s_off = write_entries(result.street_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.streets; });
   362→    auto a_off = write_entries(result.addr_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.addrs; });
   363→    auto i_off = write_entries(result.interp_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.interps; });
   364→
   365→    // Write geo_cells
   366→    for (auto& c : cells) {
   367→        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&c.cell_id, (const char*)&c.cell_id + 8);
   368→        auto get = [&](const auto& m) -> uint32_t {
   369→            auto it = m.find(c.cell_id); return it != m.end() ? it->second : no_data;
   370→        };
   371→        uint32_t sv = get(s_off), av = get(a_off), iv = get(i_off);
   372→        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&sv, (const char*)&sv + 4);
   373→        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&av, (const char*)&av + 4);
   374→        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&iv, (const char*)&iv + 4);
   375→    }
   376→
   377→    return result;
   378→}
   379→
   380→struct RebuiltAdmin {
   381→    std::vector<char> admin_cells_data;
   382→    std::vector<char> admin_entries_data;
   383→};
   384→
   385→inline RebuiltAdmin rebuild_admin_from_remap(
   386→    const std::vector<char>& old_ac, const std::vector<char>& old_ae,
   387→    const std::unordered_map<uint32_t,uint32_t>& admin_rm,
   388→    const std::vector<uint64_t>& added_cells = {},
   389→    const std::vector<uint64_t>& removed_cells = {})
   390→{
   391→    size_t n_cells = old_ac.size() / 12;
   392→
   393→    struct CellData { uint64_t cell_id; std::vector<uint32_t> ids; };
   394→    std::vector<CellData> cells(n_cells);
   395→    for (size_t i = 0; i < n_cells; i++) {
   396→        memcpy(&cells[i].cell_id, old_ac.data() + i * 12, 8);
   397→        uint32_t off; memcpy(&off, old_ac.data() + i * 12 + 8, 4);
   398→        if (off != 0xFFFFFFFF && off + 2 <= old_ae.size()) {
   399→            uint16_t count; memcpy(&count, old_ae.data() + off, 2);
   400→            if (off + 2 + count * 4 <= old_ae.size()) {
   401→                cells[i].ids.resize(count);
   402→                memcpy(cells[i].ids.data(), old_ae.data() + off + 2, count * 4);
   403→                for (auto& id : cells[i].ids) {
   404→                    uint32_t flags = id & 0x80000000u;
   405→                    uint32_t masked = id & 0x7FFFFFFFu;
   406→                    auto it = admin_rm.find(masked);
   407→                    if (it != admin_rm.end()) id = it->second | flags;
   408→                }
   409→                std::sort(cells[i].ids.begin(), cells[i].ids.end());
   410→            }
   411→        }
   412→    }
   413→
   414→    // Apply cell changes (add/remove)
   415→    if (!removed_cells.empty()) {
   416→        std::unordered_set<uint64_t> removed_set(removed_cells.begin(), removed_cells.end());
   417→        cells.erase(std::remove_if(cells.begin(), cells.end(),
   418→            [&](const CellData& c) { return removed_set.count(c.cell_id); }), cells.end());
   419→    }
   420→    if (!added_cells.empty()) {
   421→        for (uint64_t cid : added_cells) {
   422→            CellData cd; cd.cell_id = cid;
   423→            cells.push_back(cd);
   424→        }
   425→        std::sort(cells.begin(), cells.end(),
   426→            [](const CellData& a, const CellData& b) { return a.cell_id < b.cell_id; });
   427→    }
   428→
   429→    RebuiltAdmin result;
   430→    uint32_t no_data = 0xFFFFFFFF;
   431→    std::unordered_map<uint64_t, uint32_t> offsets;
   432→    for (auto& c : cells) {
   433→        if (c.ids.empty()) continue;
   434→        offsets[c.cell_id] = static_cast<uint32_t>(result.admin_entries_data.size());
   435→        uint16_t count = static_cast<uint16_t>(c.ids.size());
   436→        result.admin_entries_data.insert(result.admin_entries_data.end(), (const char*)&count, (const char*)&count + 2);
   437→        result.admin_entries_data.insert(result.admin_entries_data.end(), (const char*)c.ids.data(), (const char*)c.ids.data() + c.ids.size() * 4);
   438→    }
   439→    for (auto& c : cells) {
   440→        result.admin_cells_data.insert(result.admin_cells_data.end(), (const char*)&c.cell_id, (const char*)&c.cell_id + 8);
   441→        auto it = offsets.find(c.cell_id);
   442→        uint32_t off = it != offsets.end() ? it->second : no_data;
   443→        result.admin_cells_data.insert(result.admin_cells_data.end(), (const char*)&off, (const char*)&off + 4);
   444→    }
   445→    return result;
   446→}
   447→
   448→// --- Varint encoding for delta-compressed fixup tables ---
   449→
   450→inline void write_varint(std::vector<char>& buf, uint32_t value) {
   451→    while (value >= 128) {
   452→        buf.push_back(static_cast<char>((value & 0x7F) | 0x80));
   453→        value >>= 7;
   454→    }
   455→    buf.push_back(static_cast<char>(value));
   456→}
   457→
   458→inline uint32_t read_varint(const char* data, size_t& pos) {
   459→    uint32_t result = 0, shift = 0;
   460→    while (true) {
   461→        uint8_t byte = static_cast<uint8_t>(data[pos++]);
   462→        result |= (uint32_t)(byte & 0x7F) << shift;
   463→        if (!(byte & 0x80)) break;
   464→        shift += 7;
   465→    }
   466→    return result;
   467→}
   468→
   469→// --- Grid coordinate for fingerprinting ---
   470→
   471→inline int to_grid(float v) {
   472→    return (int)(v * 1e5f + (v >= 0 ? 0.5f : -0.5f));
   473→}
   474→
   475→// --- Directory creation ---
   476→inline void ensure_dir(const std::string& path) {
   477→    std::string cmd = "mkdir -p '" + path + "'";
   478→    system(cmd.c_str());
   479→}
   480→

    auto remap_ids = [](std::vector<uint32_t>& ids, const std::unordered_map<uint32_t,uint32_t>& rm) {
        for (auto& id : ids) {
            auto it = rm.find(id);
            if (it != rm.end()) id = it->second;
        }
        std::sort(ids.begin(), ids.end());
    };

    // Parse all cells and remap IDs
    struct CellData {
        uint64_t cell_id;
        std::vector<uint32_t> streets, addrs, interps;
    };
    std::vector<CellData> cells(n_cells);
    for (size_t i = 0; i < n_cells; i++) {
        memcpy(&cells[i].cell_id, old_geo.data() + i * 20, 8);
        uint32_t s_off, a_off, i_off;
        memcpy(&s_off, old_geo.data() + i * 20 + 8, 4);
        memcpy(&a_off, old_geo.data() + i * 20 + 12, 4);
        memcpy(&i_off, old_geo.data() + i * 20 + 16, 4);
        cells[i].streets = parse_entry(old_se, s_off);
        cells[i].addrs = parse_entry(old_ae, a_off);
        cells[i].interps = parse_entry(old_ie, i_off);
        remap_ids(cells[i].streets, way_rm);
        remap_ids(cells[i].addrs, addr_rm);
        remap_ids(cells[i].interps, interp_rm);
    }

    // Apply cell set changes (add new cells, remove old cells)
    if (!removed_cells.empty()) {
        std::unordered_set<uint64_t> removed_set(removed_cells.begin(), removed_cells.end());
        cells.erase(std::remove_if(cells.begin(), cells.end(),
            [&](const CellData& c) { return removed_set.count(c.cell_id); }), cells.end());
    }
    if (!added_cells.empty()) {
        for (uint64_t cid : added_cells) {
            CellData cd; cd.cell_id = cid;
            // New cell entry data is provided via the new_cell_entries parameter (if available)
            cells.push_back(cd);
        }
        // Re-sort to maintain cell_id order
        std::sort(cells.begin(), cells.end(),
            [](const CellData& a, const CellData& b) { return a.cell_id < b.cell_id; });
    }

    // Write rebuilt files
    RebuiltGeo result;
    uint32_t no_data = 0xFFFFFFFF;

    auto write_entries = [&](std::vector<char>& buf, const auto& getter) -> std::unordered_map<uint64_t, uint32_t> {
        std::unordered_map<uint64_t, uint32_t> offsets;
        for (auto& c : cells) {
            const auto& ids = getter(c);
            if (ids.empty()) continue;
            offsets[c.cell_id] = static_cast<uint32_t>(buf.size());
            uint16_t count = static_cast<uint16_t>(ids.size());
            buf.insert(buf.end(), (const char*)&count, (const char*)&count + 2);
            buf.insert(buf.end(), (const char*)ids.data(), (const char*)ids.data() + ids.size() * 4);
        }
        return offsets;
    };

    auto s_off = write_entries(result.street_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.streets; });
    auto a_off = write_entries(result.addr_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.addrs; });
    auto i_off = write_entries(result.interp_entries_data, [](const CellData& c) -> const std::vector<uint32_t>& { return c.interps; });

    // Write geo_cells
    for (auto& c : cells) {
        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&c.cell_id, (const char*)&c.cell_id + 8);
        auto get = [&](const auto& m) -> uint32_t {
            auto it = m.find(c.cell_id); return it != m.end() ? it->second : no_data;
        };
        uint32_t sv = get(s_off), av = get(a_off), iv = get(i_off);
        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&sv, (const char*)&sv + 4);
        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&av, (const char*)&av + 4);
        result.geo_cells_data.insert(result.geo_cells_data.end(), (const char*)&iv, (const char*)&iv + 4);
    }

    return result;
}

struct RebuiltAdmin {
    std::vector<char> admin_cells_data;
    std::vector<char> admin_entries_data;
};

inline RebuiltAdmin rebuild_admin_from_remap(
    const std::vector<char>& old_ac, const std::vector<char>& old_ae,
    const std::unordered_map<uint32_t,uint32_t>& admin_rm,
    const std::vector<uint64_t>& added_cells = {},
    const std::vector<uint64_t>& removed_cells = {})
{
    size_t n_cells = old_ac.size() / 12;

    struct CellData { uint64_t cell_id; std::vector<uint32_t> ids; };
    std::vector<CellData> cells(n_cells);
    for (size_t i = 0; i < n_cells; i++) {
        memcpy(&cells[i].cell_id, old_ac.data() + i * 12, 8);
        uint32_t off; memcpy(&off, old_ac.data() + i * 12 + 8, 4);
        if (off != 0xFFFFFFFF && off + 2 <= old_ae.size()) {
            uint16_t count; memcpy(&count, old_ae.data() + off, 2);
            if (off + 2 + count * 4 <= old_ae.size()) {
                cells[i].ids.resize(count);
                memcpy(cells[i].ids.data(), old_ae.data() + off + 2, count * 4);
                for (auto& id : cells[i].ids) {
                    uint32_t flags = id & 0x80000000u;
                    uint32_t masked = id & 0x7FFFFFFFu;
                    auto it = admin_rm.find(masked);
                    if (it != admin_rm.end()) id = it->second | flags;
                }
                std::sort(cells[i].ids.begin(), cells[i].ids.end());
            }
        }
    }

    // Apply cell changes (add/remove)
    if (!removed_cells.empty()) {
        std::unordered_set<uint64_t> removed_set(removed_cells.begin(), removed_cells.end());
        cells.erase(std::remove_if(cells.begin(), cells.end(),
            [&](const CellData& c) { return removed_set.count(c.cell_id); }), cells.end());
    }
    if (!added_cells.empty()) {
        for (uint64_t cid : added_cells) {
            CellData cd; cd.cell_id = cid;
            cells.push_back(cd);
        }
        std::sort(cells.begin(), cells.end(),
            [](const CellData& a, const CellData& b) { return a.cell_id < b.cell_id; });
    }

    RebuiltAdmin result;
    uint32_t no_data = 0xFFFFFFFF;
    std::unordered_map<uint64_t, uint32_t> offsets;
    for (auto& c : cells) {
        if (c.ids.empty()) continue;
        offsets[c.cell_id] = static_cast<uint32_t>(result.admin_entries_data.size());
        uint16_t count = static_cast<uint16_t>(c.ids.size());
        result.admin_entries_data.insert(result.admin_entries_data.end(), (const char*)&count, (const char*)&count + 2);
        result.admin_entries_data.insert(result.admin_entries_data.end(), (const char*)c.ids.data(), (const char*)c.ids.data() + c.ids.size() * 4);
    }
    for (auto& c : cells) {
        result.admin_cells_data.insert(result.admin_cells_data.end(), (const char*)&c.cell_id, (const char*)&c.cell_id + 8);
        auto it = offsets.find(c.cell_id);
        uint32_t off = it != offsets.end() ? it->second : no_data;
        result.admin_cells_data.insert(result.admin_cells_data.end(), (const char*)&off, (const char*)&off + 4);
    }
    return result;
}

// --- Varint encoding for delta-compressed fixup tables ---

inline void write_varint(std::vector<char>& buf, uint32_t value) {
    while (value >= 128) {
        buf.push_back(static_cast<char>((value & 0x7F) | 0x80));
        value >>= 7;
    }
    buf.push_back(static_cast<char>(value));
}

inline uint32_t read_varint(const char* data, size_t& pos) {
    uint32_t result = 0, shift = 0;
    while (true) {
        uint8_t byte = static_cast<uint8_t>(data[pos++]);
        result |= (uint32_t)(byte & 0x7F) << shift;
        if (!(byte & 0x80)) break;
        shift += 7;
    }
    return result;
}

// --- Grid coordinate for fingerprinting ---

inline int to_grid(float v) {
    return (int)(v * 1e5f + (v >= 0 ? 0.5f : -0.5f));
}

// --- Directory creation ---
inline void ensure_dir(const std::string& path) {
    std::string cmd = "mkdir -p '" + path + "'";
    system(cmd.c_str());
}


Now I have the full picture. The core optimization: after the merge sequence is built, match DELETE'd and INSERT'd records by a relaxed key (e.g., name_id + node_count for ways) to recover ID mappings for records that changed geometry but represent the same entity. This should drastically reduce the 2M cell corrections.

I also noticed a bug: `fixup_admin_offsets` reads country_code at `p + stride - 2` (offset 22 for stride 24), but country_code is actually at offset 20. It's reading the padding byte instead.

Let me implement both fixes.


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


Question: select:TaskUpdate


Question: select:TaskUpdate


TaskCreate

Add secondary matching to reduce cell corrections

Add secondary_match_from_merge function to geocoder_diff.cpp that matches DELETE/INSERT records by relaxed keys (name_id + node_count for ways, etc.) to recover ID mappings for modified records. Target: reduce 2M street corrections significantly.

Implementing secondary matching optimization


Task #14 created successfully: Add secondary matching to reduce cell corrections


Fix admin polygon country_code hash offset bug

fixup_admin_offsets reads country_code at p + stride - 2 (offset 22 for stride 24) but country_code is at offset 20. Reading padding byte instead.


Task #15 created successfully: Fix admin polygon country_code hash offset bug


Build and test on Europe

Build the modified diff tool and run on Europe (det-A/ vs det-today/) to measure improvement in cell corrections and patch size.


Task #16 created successfully: Build and test on Europe

Build and test on Europe


Run sequential patch test with optimized diff on planet

Re-run sequential test (planet-A → planet-B → planet-C) with all optimizations including secondary matching and delta fixups.


Task #17 created successfully: Run sequential patch test with optimized diff on planet


Updated task #14 status


Now let me implement the secondary matching. I'll add the template function and fix the admin hash bug at the same time.


Edit

// --- Derive ID remap from merge sequence (same logic as patch tool) ---
static std::vector<uint32_t> derive_id_remap_from_merge(

// --- Secondary matching for modified records ---
// After the merge sequence is built, match DELETE'd and INSERT'd records by a
// relaxed key to recover ID mappings for records that changed (e.g. geometry edit)
// but represent the same logical entity. Only matches when the key group has
// equal counts on both sides to avoid mismatches.
template<typename KeyFn>
static std::unordered_map<uint32_t, uint32_t> secondary_match_from_merge(
    const MergeSequence& seq,
    const std::vector<char>& old_data,
    const std::vector<char>& new_data,
    size_t stride,
    KeyFn key_fn)
{
    std::vector<uint32_t> del_indices, ins_indices;
    size_t pos = 0, old_rec = 0, new_rec = 0;
    while (pos < seq.data.size()) {
        uint8_t op = static_cast<uint8_t>(seq.data[pos]); pos++;
        uint32_t count; memcpy(&count, seq.data.data() + pos, 4); pos += 4;
        if (op == OP_MATCH_RUN) {
            old_rec += count; new_rec += count;
        } else if (op == OP_INSERT_RUN) {
            for (uint32_t k = 0; k < count; k++)
                ins_indices.push_back(static_cast<uint32_t>(new_rec + k));
            pos += count * stride;
            new_rec += count;
        } else if (op == OP_DELETE_RUN) {
            for (uint32_t k = 0; k < count; k++)
                del_indices.push_back(static_cast<uint32_t>(old_rec + k));
            old_rec += count;
        }
    }

    // Group by key
    std::unordered_map<uint64_t, std::vector<uint32_t>> del_by_key, ins_by_key;
    for (uint32_t di : del_indices) {
        if ((size_t)(di + 1) * stride > old_data.size()) continue;
        del_by_key[key_fn(old_data.data() + di * stride)].push_back(di);
    }
    for (uint32_t ii : ins_indices) {
        if ((size_t)(ii + 1) * stride > new_data.size()) continue;
        ins_by_key[key_fn(new_data.data() + ii * stride)].push_back(ii);
    }

    // Match only when equal counts per key (safe pairwise match in index order)
    std::unordered_map<uint32_t, uint32_t> remap;
    for (auto& [key, del_vec] : del_by_key) {
        auto it = ins_by_key.find(key);
        if (it == ins_by_key.end()) continue;
        auto& ins_vec = it->second;
        if (del_vec.size() == ins_vec.size()) {
            std::sort(del_vec.begin(), del_vec.end());
            std::sort(ins_vec.begin(), ins_vec.end());
            for (size_t i = 0; i < del_vec.size(); i++)
                remap[del_vec[i]] = ins_vec[i];
        }
    }
    return remap;
}

// --- Derive ID remap from merge sequence (same logic as patch tool) ---
static std::vector<uint32_t> derive_id_remap_from_merge(