Authoritative for scripts/import_pack.py, scripts/pack_colors.py, the data/_generated/
cache, pack provenance, and anything derived from one pack's geometry.
Part of the eql-interactive-map reference set, pointed at from AGENTS.md's routing table.
AGENTS.md states the rules; this file carries the reasoning behind them — the measured
figures, the alternatives tried and rejected, the derivations. Where the two disagree, that is a
bug to fix in place, not a precedence question.
Regenerates the traced geometry from a user-supplied map pack into the git-ignored
data/_generated/, so the repo need not carry data it does not own. It is not part of the
build and nothing reconverts automatically: an explicit re-run refreshes, and build.py
validates the manifest rather than repairing it. A silent auto-reconvert would be the same
"a cosmetic change moves data without anyone deciding it should" failure the travel graph's
authored-cost rule exists to prevent.
Coordinates round with Python's round() — half-to-even — and this is load-bearing.
Measured over all 118 non-stale zones: half-to-even reproduces the committed data 118/118,
while floor(x+0.5) (JS Math.round) reproduces 23/118. The pack has 16 938 exact .5
coordinates and detail y is negated, so the two rules disagree constantly and in both
directions. no-install-builder's JS twin therefore cannot use Math.round — it needs an
explicit half-to-even helper, and the cross-language identical-output test is what will catch
it. Note the colour fallback in pack_colors.py deliberately uses the other rule; two
rounding rules in one converter is a trap, so both are commented where they sit.
Injected JSON uses JavaScript's number dialect, even when its source file does not. Python's
encoder preserves an integral float as -1114.0, while JSON.stringify emits -1114; worse,
Python parses those spellings as different types, so the order-sensitive datacmp sees them as a
real difference. The authored layer contains 382 .0 floats (302 in travel.json, 80 across
ten continent.json files), computed detail bboxes add 409, and one authored value is -0.0.
By contrast, browser-exported layout.json and world.json contain none, so canonicalising all
JSON through build.py's one loader makes injection consistent with the editor's existing dialect
rather than creating a third one.
The loader returns an integer whenever a parsed float is integral, rejects Python's non-standard
NaN/infinity extensions, rejects nonzero fractional values below 1e-4, and keeps every integer
within JavaScript's conservative safe range (2^53 - 1). The lower float guard is about notation:
Python switches to an exponent spelling before JavaScript does. The integer guard is about precision:
9007199254740993 is preserved by Python but parses and re-emits as 9007199254740992 in
JavaScript. Some larger integers happen to round-trip exactly, but accepting a scattered subset is
a brittle contract and would not make arithmetic on them safe.
There are no rejected values in the tree today. Applying Python callbacks to generated geometry is
the cost of keeping the rule in one loader: one measured build made 4,932,196 integer-callback
calls and 1,460,652 float-callback calls, overwhelmingly over generated coordinates and preserved
segz that build composition does not consume. Nine alternating warm samples in one process,
against the same cache, measured the complete safe-number loader at 1.392 s median
(1.334–1.415) and the exact float-only parent at 0.930 s (0.913–0.955), a +0.462 s
increment. The largest injected integer is only 52,206. The guard remains deliberate despite
that headroom: an ordinary Python build and the browser twin must reject the same unsafe input
domain, rather than relying on a later optional verification run to discover that they accepted
different inputs. A specialised geometry loader would avoid much of the cost but split one
invariant across two code paths, so the single loader remains the trade until measured authoring
latency justifies a different validator.
src/pack_convert.js receives bytes and authored objects through callbacks; it does not own a
filesystem, DOM API, cache, or copy of PACK_COLORS. The table crosses the language boundary as
comma-separated RGB keys, while lift() is the sole hand-translated colour function. Keeping the
table as an argument prevents a second rendering-decision source from drifting.
Python's float() grammar and JavaScript's numeric conversions disagree in both directions:
JavaScript accepts blank strings and base-prefixed integers that Python rejects, while Python
accepts embedded digit underscores and inf/nan spellings that Number() does not reproduce
uniformly. The twin validates the ASCII form first, removes only legal digit separators, and then
converts. It deliberately rejects Python's Unicode decimal digits: no pack uses them, and a loud
rejection is safer than silently accepting a different value.
Python's int(float(v)) truncates toward zero, so its JavaScript twin is Math.trunc. Math.floor
is wrong for negative values, while | 0 additionally narrows the result to a signed 32-bit
integer.
Python splitlines() recognizes bare CR, vertical tab, form feed, record separators 0x1c–0x1e,
NEL, and Unicode line/paragraph separators in addition to LF; Python strip() also differs from
JavaScript trim() at 0x1c–0x1f, NEL, and U+FEFF. The twin spells out both sets. This is
parser parity rather than robustness: a different split changes which record owns a field-count
failure.
TextDecoder('latin1') is the WHATWG Windows-1252 decoder, not Python's ISO-8859-1 fallback. The
twin maps fallback bytes directly to same-valued code points. Its UTF-8 decoder already consumes
one leading BOM, matching Python's explicit one-BOM strip; adding a second JavaScript strip would
wrongly remove a second BOM that Python preserves, while stripping the Latin-1 path would be wrong
for the opposite reason.
The browser path has no generated-cache round trip, so it re-applies the cache loader's number
domain explicitly: finite values only, no nonzero fraction below 1e-4, and no magnitude beyond
JavaScript's safe integer range. It walks the eight composed structures and validates preserved
segz when produced, reporting the original value and its path; otherwise the twin could emit an
artifact the ordinary Python build refuses.
The map's eight data placeholders and separate credit/version placeholders are assembled in one
non-recursive pass over the original stripped template. Sequential replacement would let a pack
name or injected string containing a later placeholder spelling become active template syntax.
The browser twin also uses a function replacement so JavaScript cannot reinterpret replacement
tokens such as $&; exact parity would not reveal either error until an input happened to carry
one of those spellings.
A zone's off is a FLOAT pair and translation happens before rounding. Only 14 of 118
offsets are integral. This is what AGENTS.md previously recorded as "exact-integer agreement
is only 20/120" between the detail and continent frames — that figure is not evidence of the
two files being rounded independently, it is the signature of a fractional offset, and it
disappears once the offset is stored as a float. Applying an integer offset to the
already-rounded detail value reproduces exactly those 20 zones and no more.
Palette allocation iterates detailZones, never zoneOrder. They differ — Odus lists
erudnext, tox, erudnint, … against erudnext, erudnint, tox, … — and both orders yield a
palette of the same length, so a zoneOrder traversal permutes the tail with no length error
to notice it by. Indices are embedded in every detail seg, so that is a wrong colour on every
seg using them. First-seen over detailZones × layers base,_1,_2,_3 × L and P records
alike: a label's colour shares the seg namespace, which is why P lines participate. Colours for
authored labels that survive composition are appended afterward only when absent. Appending keeps
every traced index stable; limiting it to surviving labels keeps a richer pack from acquiring an
unused authored colour merely because it supplied the same label itself.
Pack RGB → display hex is a table, not a rule, and cannot be fitted. The original importer
lifted the pack's colours for a dark canvas — (255,0,0) ships as #ff4545, black as
#afafaf — and it is not in this repo. Hue is preserved and dark colours are lifted, but the
shape is refuted by one pair: (0,50,0) and (0,64,0) both normalise to (0,205,0) yet ship
as (9,205,9) and (8,206,8), so no "normalise then adjust" pipeline can separate them. The
83-entry table in scripts/pack_colors.py was recovered from the committed palettes, verified
collision-free across all 11 continents. lift() approximates an unseen colour and the
manifest counts every such hit, so a pack needing many is visible rather than silent. Do not
spend time re-deriving the transform — that search is closed.
A zone may author labels the pack does not carry, and Ocean of Tears is the only live case.
continent.json → zones[zk].labels holds [x, y, "#hex", size, text] entries that
compose_detail appends to the traced ones. It exists because re-importing oot dropped
to_East_Freeport and to_Butcherblock_Mountains, and those two were never pack content —
in the pre-split committed file they sat after the _2 attribution block, and the import
concatenates base/_1/_2/_3 in that order, so nothing from the pack can follow the credits.
They also reused an existing palette slot where a real 240,0,0 label would have allocated its
own, and carried size 3 where every traced label in that zone is size 2. They had been
hand-written into a file the data model called "static imported source". So this is not an
exception to the licensing boundary: it moves authored data into the layer it always belonged
in, exactly as name/color/cx/cy were promoted. (The pack's separate oceanoftears
variant carries the same connection as one &-joined label, which is evidently the source.)
Two rules keep it from becoming a hatch for putting regenerated content back under version control. An authored label may only supply what the pack lacks, and the pack always wins a collision — the authored copy is skipped, never duplicated and never allowed to override. And the colour is authored as a hex resolved against the palette at compose time, never as an index: indices are assigned during conversion and shift when a pack changes, so a stored index silently recolours. Conversion appends a surviving authored label's missing hex after every traced colour, preserving all generated indices. A hex still missing at compose time is a hard error: it means the cache and authored layer were not converted together, rather than inviting a guess.
A collision is a counted notice and must not be an error, and the reason is multi-map-pack.
Whether a pack ships a given label varies by pack — Brewall's oot_1.txt has no transition
markers while the pack's own oceanoftears variant carries that same connection — so failing
the build would punish whoever installed the richer pack. convert() records every collision in
manifest.authoredLabelCollisions with continent, zone and label text, so "the authored entry
may no longer be needed" is reported without being decided.
AGENTS.md states the rule: --pack's parent, when named maps, becomes a base layer the
chosen pack overwrites per zone. The reasoning:
The authored roster is a candidate list, not a pack-coverage requirement. convert() still
walks continent.json rather than discovering zones from the pack — names, placement and colour
remain authored decisions — but a rostered zone absent from both layers is skipped instead of
aborting the whole continent. Its key is recorded in that continent's skippedZones, while
zones remains the full authored roster: the former says what this pack could supply and the
latter says which authored roster the cache was built against. Keeping those claims separate is
what lets authored completeness remain pack-independent, and putting the skip list under the
continent is what lets it survive an --only seed.
Missing and unusable are deliberately different states. No source directory means a coverage
gap and takes the skip path. A chosen directory whose zone files contain no L or P record is a
broken pack and remains a hard error: falling through or treating it as absent would let a blank
pack file silently select another source, reintroducing the cross-source mixing that per-zone
all-or-nothing resolution makes unrepresentable.
The measured root-only result is 83 surviving zones and 37 skips across six continents. The
skips are Antonica 4, Faydwer 1, Odus 2, Kunark 17, Velious 12 and Plane of Hate 1. Plane of Hate
therefore remains in ALL with an empty zones object and its one-key skip list, while DETAIL
and HUBS omit it. Keeping every continent preserves authored order and keeps the partial-build
author interlock observable even when a whole continent is missing.
Composition filters only structures whose identity depends on a surviving zone. Detail and
geometry skip the absent key. HUBS is never compacted because travel anchors address the
published array positionally; the entire set is omitted only when the continent has no surviving
zone. Links with a skipped endpoint are filtered because otherwise the sparse overlay records a
false user deletion (14 of Kunark's 19 links, against none of Antonica's 11, in the root-only
measurement). Connectors are coordinate pairs whose ref is only a hint, so assigning one to a
zone at build time would be forbidden re-derivation. placed and unplaced have no runtime or
export consumer and stay whole rather than creating a second roster claim.
Why per-zone all-or-nothing rather than per-layer. A merged zone is two different tracings of
the same room superimposed — the 69/120 divergence and arena.txt's 3480-vs-503 L lines are the
measure of how different. The design makes mixing unrepresentable rather than tested-against:
resolution returns one directory, which parse_zone and zone_files both take, so there is
no code path that could interleave two sources. Measured cost of all-or-nothing: 0 zones under
Brewall, 4 under Good's Maps (commons, highpass, innothule, kerraridge, whose base
lives in the pack while only the root has a _1 POI layer). Those four keep the pack's tracing
and forgo the root's labels — the correct trade, but it means "all-or-nothing loses nothing" is
true of Brewall specifically, not of packs in general.
Why root is derived, not a flag. <install>/maps/<Pack> is the only layout the client
supports for a manually installed pack, so the parent directory already carries the answer; a
--maps-root flag would let the two disagree and data/pack.local.json would need a second
field. --pack pointed at the root therefore derives nothing (its parent is the install
directory), which is the decided behaviour rather than an oversight.
Why the derivation is lexical — abspath, not realpath. Layering follows the path the user
typed. Resolving junctions or symlinks would switch layering on or off for the same directory
reached under two aliases, so the same pack would import differently depending on which spelling
was used. The normcase(parent) == normcase(a) guard in root_layer cannot fire today — only a
filesystem root is its own parent and a root's basename is empty, which the maps test already
rejects — so it is there to keep a future loosening of that test from reintroducing a
self-resolving source. Note maps/maps is not the pathological case: it resolves to the root
above it, which is correct.
The cascade's derivation is one measured instance. Orphan zones — a directory with _N layer
files and no base <zone>.txt — number 12 in Brewall, 22 in Good's Maps, 0 in the
root. Those figures are case-FOLDED, and the folding is the point: the resolver's probe is
os.path.exists, which is case-insensitive on Windows. Case-sensitive set arithmetic gives 22 and
23, over-counting Brewall by 10, so anyone re-measuring pack coverage must fold — and a pack's
effective coverage is therefore filesystem-dependent, which nothing else in this repo is. Exactly
one orphan shadows a root base file under either folding: Brewall's tutorial, which carries
tutorial_1.txt and no tutorial.txt while the root has tutorial.txt. A naive "any pack layer
file wins" resolves it to the POI layer, yields empty geometry (continent geometry is the base
layer alone), and passes validate_cache because the file exists — a zone that renders as
nothing. That single instance is the whole reason the cascade is base-first, and why
baselessZones is recorded per continent and asserted rather than merely printed. A possible
follow-up, not done here: validate_cache could reject a geometry file with zero segs.
Why the SCHEMA bump is not hygiene. validate_cache never reads sources, so a schema-1
cache would keep building fine. What the bump makes unrepresentable is a schema-1 manifest seeded
into a schema-2 --only run: it would contribute continents with no from/rootZones, so the
summary would report "no zones from the root layer" while an untouched continent had one — a
silent false negative on a claim that now carries licensing weight. --only calls
validate_cache before seeding, so the stale manifest is rejected with cache schema 1, expected 2 instead.
The 2→3 bump closes the same hole for partial coverage. A schema-2 continent has no
skippedZones; seeding it unchanged into a schema-3 --only run would make the merged manifest
under-count gaps on every untouched continent. Schema 3 also validates the field before treating
it as authoritative — list of strings, unique, and within the authored roster — because an invalid
skip declaration otherwise turns a healthy generated file into content the builder silently drops.
File absence alone never grants the exemption: only declared membership does, so an undeclared
cache hole remains corruption rather than becoming a skip.
Anything that must survive --only lives under manifest["continents"][cont]. The --only
seed copies only continents; the counter-example is unknownRecords, which is top-level and
therefore already under-reports on such a run. That is why from, rootZones and
baselessZones are per-continent. The root-layer summary is gated on the zone list, never on
top-level root, for the same reason: an --only run whose --pack sits outside maps/ nulls
top-level root while untouched continents still carry rootZones.
The skip notice has the opposite scope on purpose: it lists only the continents converted by this
run, and its supplement hint is chosen from this run's resolved root. A merged manifest can carry
skips from an untouched continent converted under different layering; combining those old skips
with the current run's top-level root would confidently give the wrong remediation. Restricting
both the list and hint to the current order makes their provenance agree by construction.
looks_like_root_maps warns about a mistake, not about a licensing regime — word it from
usability, never from provenance. Its durable costs are that the root supplies 83 of the 120
rostered zones — none of Kunark, none of Velious — and that the pack's tracings are lost on the
69 zones where both directories hold a same-named file, when pointing --pack at the pack would
have yielded both layers. Its own message ends by saying so. "Root geometry is Daybreak-authored
rather than community-pack" describes a root build accurately but is not a reason to warn, since
a root-sourced prebuilt is a deliberate deliverable; it also invites the licensing-signal reading the
next paragraph rejects.
from names the LAYER, not the licensing regime. "pack" means "the directory --pack
named". That is a community pack in every supported invocation, but a user who points --pack
straight at maps/ gets every Daybreak file tagged "pack" — nothing is wrong with the tag, yet
packNote asserts that directory is a community pack and that assertion goes false. Only the
root layer is known to be Daybreak-authored; read the pack layer's regime from pack plus
warnings. Rejected: gating packNote on looks_like_root_maps having fired. That heuristic
keys on a directory name and a *_2.txt count (it fires hard on the real root — 2 grid files
against Brewall's 577), so gating a licensing string on it makes the regime statement
probabilistic, which is worse than a note that is occasionally over-broad.
The build credit follows the same evidence boundary. A selected directory whose basename is
maps is described neutrally as the selected maps folder; the name alone cannot establish who
authored its contents, for the same reason looks_like_root_maps only warns. A non-empty
per-continent rootZones list is stronger evidence: it records zones resolved through the derived
base layer, so the mixed-source clause may name how many came from the game's own maps. The exact
credit format is canonical because the browser converter must reproduce the Python artifact byte
for byte.
from is excluded from sourceFingerprint deliberately. Identical bytes from two
directories produce identical geometry, so every geometric claim printed beside the digest holds
either way; the provenance record is sources[].from, and the pair
(sourceFingerprint, rootZones) already answers "same content, different provenance".
Two consequences of a root-sourced zone, neither a fault. Palette allocation is first-seen
over detailZones, so a root-sourced zone early in that list renumbers the tail — exactly why
the whole-continent minimum exists. And root newsebexp uses 7 distinct RGB values, 2 absent
from pack_colors.PACK_COLORS — (160,120,60) → #cd9a4d and (85,184,20) → #5fcd16 — so
the first rostered fallback zone makes unseenColors non-empty for the first time since that
table was recovered. That is the report path working. (Confirmed by converting a scratch tree with
newsebexp rostered; it cannot fire in the repo's own data/, where no zone is rostered.)
Out of scope, and measured: deriving a fallback zone's off from the transition-marker pair.
sebilis sits at x 12138…13908 / y 11620…14166 while trakanon, the zone its door is in, is at
x 3867…14442 / y −11684…−4282 — ~16 000 units apart with no overlap. This atlas parks dungeons
in clear space rather than aligning them to their doorway, so marker-pair derivation would propose
a placement the atlas does not use.
The rule generalises past labels, and multi-map-pack is what makes it bite: the packs share a
coordinate frame but differ in coverage (L-line ratio 0.22–1.55, bbox delta up to 4 289 units
on overthere), so weld detection, proposals and doorways all differ by pack.
Identify a pack by content, never by path. The same path can hold a different revision and
the same pack can sit at different paths, so manifest.sourceFingerprint is a sha256 over
sorted (filename, sha256) pairs and the path is only a human hint. --audit prints both.
And overrides rulings are pack-conditional, so the audit reports rather than prescribes. A
ruling exists because some pack's geometry could not derive an edge; this proposal agreeing is
not evidence that every supported pack's does. Advice to "drop this entry" would ping-pong
between packs — it says review the ruling across supported packs instead. The blast radius stays
small only because --audit never writes and is not a build step; authored edges and costs are
untouched, so pack choice can never move routing. Per-entry pack/reason metadata is the natural
growth when multi-map-pack is actually built.
Do not hand-edit anything under data/_generated/ — it is regenerated, so the edit
evaporates on the next import. Authored labels are the supported route.
The cache is lossless where the committed data was not: it keeps segz (Z) and seglayer
(the layer role), both discarded by the original import and both blocking a roadmap item
(level-filter, 3d-zones). Nothing at build time reads them; they exist so the next consumer
needs no second re-import. That is why it is 28 MB against the 17.6 MB it replaces — irrelevant,
since it is git-ignored.
AGENTS.md states only that the cache keeps segz and that nothing at build time reads it. The
measurements behind the level-filter / 3d-zones roadmap items live here.
Stacking is real and worst in the dungeons the roadmap names. Measure it per cell (rasterise a zone's XY at 50×50, count distinct Z clusters within each cell at a 15-unit gap): Velketor's is 74% of cells carrying 2+ levels at a 78-unit median spread, then Blackburrow 56%/37, Highkeep 56%/26, Hole 45%/16, Sebilis 40%/8. Median spread is the discriminator, not the percentage — Qeynos reaches 24% of cells at a 1.0-unit spread, which is one surface with a wall on it, not two floors.
Two instruments that look right and are not: footprint overlap between the two Z-halves cannot separate a continuous surface at varying height from superimposed floor plans, and ranks dungeons below outdoor zones, i.e. exactly backwards; and global Z banding finds one band in every dungeon, because connecting passages make Z continuous across the whole range. Deriving per-segment level ids therefore means connected-component labelling over the segment graph, not a threshold.
AGENTS.md's licensing boundary carries the rules about which regime a pack file belongs to. The
evidence behind them:
- The 1:1 correspondence between a pack file's
Llines andsegs— in order, under a per-zone translation plus the Y-flip — was checked on 16 zones across 12 continents. <zone>.txtis line geometry: 117 of the 120 carry noPline at all, and the three that do (highkeep,runnyeye,unrest) carry five or six. All 120 zones carry at least one layer file in the pack layer — a claim about the pack specifically, not about resolution, which can now also reach the root — and_3exists for exactly one zone.- That
geometry/*.jsonis the base file alone anddetail/*.jsonis base+_1+_2+_3concatenated in that order was not inferred from totals — it was checked segment-by-segment against the concatenated source stream, and since the re-import it holds 120/120 exactly, byte for byte.tools/verify/test_import_pack.pyguards it. The superseded 119/120 and 118/120 figures were the two stale zones (nektulos,oot) and are gone.