Skip to content

World layers: caves, world border, water limit — and game_data reorganisation - #14

Closed
valentinps wants to merge 11 commits into
mainfrom
world-border
Closed

World layers: caves, world border, water limit — and game_data reorganisation#14
valentinps wants to merge 11 commits into
mainfrom
world-border

Conversation

@valentinps

Copy link
Copy Markdown
Owner

Three static world layers the in-game map never draws, plus the game_data/
reorganisation that landing them forced.

Draft on purpose — this needs a green CI run before it is merged, because
it is the first build that bootstraps entirely from the game-data archive
rather than from committed tables (see "Requires" below).

The World layer

One "World" sidebar category, default hidden like the other world layers,
with three independently-toggleable rows:

  • Caves (approximate) — 84 cave systems outlined. Nothing in a save
    records a cave, so this is traced from the cooked level data: the game's own
    cave atmosphere volumes (the fog/lighting regions it swaps you into
    underground — 108 of them, ~25 carrying authored names like
    Atmosphere_SwampCave), the BP_CaveFloor tunnel splines, the cave-only
    foliage clusters and the placed cave rock kit, unioned and contoured. The
    row says "approximate" and hovering it says why: the outline runs generous
    by tens of meters and can miss a bare tunnel. It finds a cave; it does not
    survey one.
  • World border — the safe side of the out-of-bounds damage volumes
    (FGDamageOverTimeVolume carrying a /World/Hazard/WorldPerimeter/ DoT
    class), as a 7-vertex polygon, 7,915 × 7,049 m, with the altitudes the
    damage starts at (+2,000 m / −244 m) in the tooltip. Three of the eleven
    walls are rotated, cutting the NE/NW/SW corners — the NE one is what fences
    off that big empty landmass in the top-right of the map render.
  • Water limit — where the swimmable, extractor-valid water actually ends
    (the union of 270 FGWaterVolume actors, all mResourceClass Desc_Water_C).
    The rendered ocean is a different thing entirely: 31 water-plane patches,
    the biggest 51 × 34 km. The sea you can see spans ~70 km; the water you can
    touch stops within ~8 km, and on the west side it ends 334 m before the
    damage wall — a strip that looks like ocean and holds nothing.

Both new tables ride along as static payload steps, same shape as the
existing spawners step.

game_data: one rule — if a script wrote it, it is not in git

The old split (sav_data/ committed vs generated/ gitignored) read as
"world tables vs Docs.json extracts" but was really the IP line, and
membership had drifted: creatureSpawners.json (coordinates, like the
committed slugs) was gitignored, half the hand-written tables sat in
sav_data/ and half at game_data/ root, and two generated files had no
consumer at all.

game_data/curated/          committed    hand-maintained inputs only
game_data/generated/docs/   gitignored   Docs.json extracts
game_data/generated/world/  gitignored   level-export tables
game_data/generated/        gitignored   map render + tiles

Two latent bugs had to be fixed for that rule to actually hold:

  • freeDroppedItems.json read its own last output. FModel cannot decode
    the cooked FInventoryItem struct, so the extractor recovered each pickup's
    item class from the previously committed table — delete it and all 703
    pickups lose their item. That knowledge is now a committed input,
    curated/pickupItems.json; --items-from-save writes what it learns back
    into it.
  • Key order was inherited from the previously committed file, so a fresh
    machine produced different bytes than an incremental re-run. Tables are now
    written sorted: same dump in, byte-identical files out.

Verified by deleting generated/{docs,world}/ and running the full
extract_all.py: every table came back byte-identical to the pre-reorg ones.
sav_core's build script now names the command to run when the data is
missing, instead of failing on the first include_str!.

Requires

game-data-v3 (published, --latest=false so the desktop updater's
releases/latest/download/latest.json is untouched). The v2 archive predates
the split and carries no world/ tables — they used to be committed — so CI
cannot compile against it. The workflow in this branch points at v3.

What CI is being asked to prove

  1. A tree with nothing generated committed can bootstrap from the archive
    alone and compile sav_core + the wasm build.
  2. The 23 sav_core tests still pass, including the new ones pinning the cave
    rings, the map-limit rings and the synthetic-id : rule.

valentinps and others added 11 commits July 28, 2026 22:12
Nothing in a save (or docs.json) records a cave, so the source is the
cooked world-partition export: the game's own cave atmosphere volumes
(the fog/lighting regions it swaps you into underground -- 108 of them,
some with authored names), the BP_CaveFloor tunnel splines, the cave-only
foliage clusters and the placed cave rock kit. extract_caves.py unions all
four, closes the gaps between one cave's separately-authored chambers, and
traces the outline of each connected component into the committed
game_data/sav_data/caves.json (84 caves, 61 KB).

The core embeds that table and emits it as a new static payload step, and
the frontend draws every ring in one line bucket under a "Caves" sidebar
row (default hidden like the other world layers), with the cave's name,
area and altitude range in the tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two invisible edges, both authored level geometry with no representation in
a save:

- The damaging border is 15 FGDamageOverTimeVolume actors whose DoT class
  comes from /World/Hazard/WorldPerimeter/. Eleven are vertical walls (four
  axis-aligned, three rotated blocks cutting the NE/NW/SW corners, four more
  as an outer backstop); the safe side is a 7-vertex polygon, 7915 x 7049 m.
  The other four span the whole world in XY and are altitude limits instead:
  damage above +2000 m and below -244 m.

- The water limit is the union of the 270 FGWaterVolume actors (all carry
  mResourceClass Desc_Water_C, i.e. swimmable and extractor-valid). The
  rendered ocean is a different thing entirely: 31 water-plane patches, the
  biggest 51 x 34 km, so the sea you can see runs ~70 km while the water you
  can touch stops within ~8 km -- on the west side the real water ends 334 m
  before the damage wall, with visual-only ocean in between.

The perimeter polygon is written as an exact half-plane intersection of the
wall boxes and cross-checked both ways against an independent rasterize-and-
trace of the same volumes (they agree to 40 cm shy of a cell).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a "Map Limits" sidebar category (default hidden, like the other world
layers) with one row per ring: the world border in red, the water limit in
cyan, each a line bucket with the facts in its tooltip -- the altitudes the
damage slabs start at for the border, and "8.2 km of real water inside a
70 km ocean" for the water.

The rings ride along as a new static payload step fed by the embedded
worldBounds.json, the same shape as the spawners step.

Two things worth knowing for the next static layer:

- ring z is 0 (sea level), not the volumes' real -9.6..+10.4 km span: a
  line's z only drives the altitude filter and depth sorting, so the honest
  span would just make the tooltip claim the border sits 10 km up.
- synthetic ids need a ':' in them. Bulk id arrays get the instance-name
  prefix re-added client-side (slim_payload_value / expandPayloadIds), which
  silently turned "worldPerimeter" into "Persistent_Level:PersistentLevel.
  worldPerimeter" and broke the color lookup keyed off it. The frontend now
  keys off a separate `kinds` array, and a test pins the ':' rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caves outlines, the damaging world border and the water limit are three
answers to the same question -- what is the terrain actually like here? --
and none of them belongs to the save being viewed, so they now share one
sidebar category instead of two. Three rows, three colors, one checkbox
each; "Map Limits" is gone as a name.

Payload steps stay separate (caves + mapLimits), so the collectors and their
tests are untouched apart from the step count going to 20.
The old split was game_data/sav_data/ (committed) vs game_data/generated/
(gitignored), which read as "world tables vs docs.json extracts" but was
really "the IP line we drew", and membership had drifted: creatureSpawners
(coordinates, like the committed slugs) was gitignored, half the hand-written
tables sat in sav_data/ and half at game_data/ root, and two generated files
had no consumer at all.

Now:

  game_data/curated/          committed    hand-maintained inputs only
  game_data/generated/docs/   gitignored   Docs.json extracts
  game_data/generated/world/  gitignored   level-export tables
  game_data/generated/        gitignored   map render + tiles

so a checkout is either extracted (extract_all.py) or unpacked
(package_game_data.py unpack), never half-committed. game_data/README.md
states the rule where the folders are; sav_data/ is gone, and so are
sav_data_gaps.json (no consumers) and the flat generated/*.json layout.

Two things had to be fixed for "nothing generated is committed" to actually
hold, both of which were latent bugs in the old layout:

- freeDroppedItems.json recovered each pickup's item class from its OWN last
  output (FModel can't decode the cooked FInventoryItem struct). Delete the
  file and all 703 pickups lose their item. That knowledge now lives in the
  committed curated/pickupItems.json, an input; --items-from-save writes what
  it learns back into it.
- every table's key order was inherited from the previously committed file,
  so a fresh machine produced different bytes than an incremental re-run.
  Tables are now written sorted: same dump in, byte-identical files out.
  Verified by deleting generated/{docs,world}/ and re-running extract_all --
  every table came back byte-identical to the pre-reorg ones.

Also: package_game_data packs generated/ recursively so the new subfolders
need no change there, but an archive built before this commit carries no
world/ tables (they used to be committed), so CI now points at game-data-v3
-- publish the rebuilt zip under that tag or CI cannot compile the crate.
sav_core's build script now says which command to run when the data is
missing instead of failing on the first include_str!.

NOTICES/README: the distribution clause said game-derived data is "not
distributed here" while CI downloaded exactly that from a release. Reworded
to say what is true -- none of it is in the repository, and the archive is a
convenience build of your own extraction.
The row now reads "Caves (approximate)", hovering it explains why, and a
cave's tooltip carries an "Outline: Approximate" line where someone reading
a size off it will see it.

The number is honest about its source: an outline is the game's cave fog
volume unioned with the cave geometry inside it, which runs generous by tens
of meters and can miss a bare tunnel with no cave props in it. That finds a
cave; it does not survey one, and the layer should not imply otherwise.

row.hint is new on leaf rows -- the long version of a caveat a label can only
gesture at, shown as a title on hover.
…atching

Three separate frictions in the search bar, all of them cases where the
answer was already on screen but out of reach.

A collectable item's suggestion row now carries the same show/hide eye a
building or layer row has, flipping that pickup's own Collectables rows.
Searching "Mercer Sphere" honestly means two things -- what is stashed in
inventories, and what is still lying out there -- and only the first had an
answer. The eye drives the real sidebar checkboxes rather than a second
state of its own, and appears on exactly the six items that have a layer at
all: the three slugs, Somersloop, Mercer Sphere, Hard Drive.

Every individual location in an item's result list gets a crosshair button
that flies the map to that one machine and marks it with a solid pink pin
(every other pin on the map is white-filled, so this one cannot be mistaken
for scenery). Coordinates told you where a building was; this takes you
there. It is deliberately independent of "Show only these on map" -- "where
is THIS one" is worth asking without blanking every other layer for
context -- and hands over to the same banner, so Details reopens the list
exactly where it was.

Finally, a query's words may now match anywhere in a label, in any order, so
"coal node" finds "Coal Ore (Resource Node)". The parenthesised kind suffix
is a real part of those names and exactly how people say them, but no
contiguous substring spans it, so the old strict test could never reach past
it. Tighter matches still rank first, so "iron" keeps surfacing Iron Plate
ahead of Reinforced Iron Plate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map deliberately stops short of the right edge -- the altitude rail is
docked there -- but the panels that float over it are body-level siblings of
#map, so their left:50% was the WINDOW's center: half a rail-width (32px)
too far right, at every viewport size. On its own that drift is invisible;
under the search pill, which is genuinely centered on the map (#topBar
reserves the same rail width in its right padding), the filter banner sat
visibly off.

One --map-center-x for all four of them (filter banner, selection panel,
editor toolbar, editor hint), explained once where it's defined.

Also moves a group header's "× 28" badge from the right edge to right after
the building name: "Constructor × 28" is one phrase and now reads as one.
The label gives up its flex stretch and the badge takes over the
gap-filling, so the count column stays exactly where it was -- aligned with
every non-grouped row's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Search: collectable layer toggles, per-location "Show on map", word-order matching
A planning tool for "where should the belts/trains/power actually run".
Reachable only by typing its name in the search bar (a new "tool" catalog
entry, so it costs no permanent UI), it takes a set of points -- clicked
empty map, clicked buildings, the whole rectangle selection in bulk, or
typed X/Y metres -- and draws the shortest set of links joining them.

Two link styles, both exact and both O(n log n) (map/static/map/emst.js):

  Straight    Euclidean MST. Sits inside the Delaunay triangulation, so a
              sweep-hull triangulation cuts the candidate set from n^2/2
              edges to under 3n and Kruskal does the rest.
  X / Y only  Rectilinear (L1) MST -- NOT the straight tree with corners
              drawn on: changing the metric changes which points the optimal
              tree even connects. Sits inside the octant graph, and inside
              one octant the L1 distance collapses to a linear function of
              the far endpoint, so four Fenwick sweeps find all eight
              nearest neighbours at once.

Optionally one point is a DESTINATION, and a slider trades total network
length against how far every point has to travel through the tree to reach
it (Prim-Dijkstra: Prim with the key alpha * tree-distance + edge weight).
alpha = 0 is Prim, so still the exact MST; alpha = 1 is Dijkstra over a
candidate set augmented with the destination's own edges, so every point
joins it directly -- the exact minimum of summed trip distance. Between the
two it is a heuristic trade, and the panel says so rather than implying an
optimum that is NP-hard to find. On 5,700 real machines, 40% along the
slider costs 9% more network and cuts total travel by 47%.

Everything is computed in map pixels: the world projection is one uniform
scale with no rotation, so an optimal tree there is optimal in metres, and
"along X" on screen is "along X" in the world.

tools/check_emst.js is the gate. A short candidate set still yields a
spanning tree, just a silently longer one, so every result is diffed
against brute-force Prim over grids, circles, collinear runs, duplicates
and clusters -- plus, with a destination, that alpha=0 is still the MST,
that alpha=1 is the exact star, and that the trip distances the panel
reports match a walk of the tree it actually drew. It caught an inverted
in-circle sign on its first run. 100,000 points solve in about a second.

Also here, both found by using the tool:

- selection.js: selecting a PIN (vehicles, collectables, players, crash
  sites) marked the bare coordinate, which is where the tail points -- the
  pin itself, floating above it, looked untouched. Now ringed, with the
  same geometry the hover highlight already uses.
- The same object can no longer be added twice: points carry the object's
  save id, so clicking a 100m foundation's far corner counts as the same
  object rather than a second point. Clicking it again removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Optimal network finder (EMST): shortest links joining a set of points
@valentinps valentinps closed this Aug 1, 2026
@valentinps
valentinps deleted the world-border branch August 1, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant