Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ jobs:
path: |
game_data/generated
map/static/map/icons
key: game-data-v2
key: game-data-v3
- name: Fetch game data
# sav_core embeds game_data/generated/*.json at compile time.
# sav_core embeds game_data/generated/{docs,world}/*.json at compile
# time, and NOTHING generated is committed, so this archive is the only
# way CI gets a compilable tree. game-data-v3 is the first archive built
# after the generated/{docs,world}/ split -- v2 predates it and carries
# no world/ tables (they used to be committed under game_data/sav_data/).
if: steps.game-data.outputs.cache-hit != 'true'
run: |
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v3/game_data.zip
python3 game_data/package_game_data.py unpack game_data.zip
- name: Cache test saves
uses: actions/cache@v4
Expand Down Expand Up @@ -59,11 +63,11 @@ jobs:
path: |
game_data/generated
map/static/map/icons
key: game-data-v2
key: game-data-v3
- name: Fetch game data
if: steps.game-data.outputs.cache-hit != 'true'
run: |
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v3/game_data.zip
python3 game_data/package_game_data.py unpack game_data.zip
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
Expand Down
14 changes: 8 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ map/uploads/
# see README.md's "Generating game data" section.
game_data/docs.json

# Everything derived from game files: the JSONs generated from docs.json by
# game_data/extractors/extract_docs_json.py, gamePhases.json from
# game_data/extractors/extract_game_phases.py, and map_highres.png from
# game_data/extractors/extract_map_image.py. game_data/SCHEMA.md (hand-written) sits one
# level up on purpose so this whole folder stays regenerable/disposable.
# Everything game_data/extract_all.py produces: docs/ (from docs.json),
# world/ (from the FModel level export) and the map render. Nothing generated
# is committed -- a checkout gets this folder by running the extractors or by
# unpacking game_data.zip. The hand-maintained inputs live in
# game_data/curated/ and ARE committed; see game_data/README.md.
game_data/generated/

# Icon PNGs (regenerate via game_data/extractors/copy_icons.py; kept under map/static/
Expand All @@ -45,4 +45,6 @@ rust_parser/target/
# Assembled static site (tools/build_site.py output)
dist/
LAUNCH.md
.wrangler
.wrangler
# Screenshot-harness output (tools/ui_shots.py)
ui_shots/
48 changes: 36 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,19 @@ same `dist/` still serves in the browser via `serve_site.py`.
| Path | Contents |
| --- | --- |
| `map/static/map/` | the web frontend (vanilla JS + Leaflet + WebGL layer, `worker.js`/`save_client.js` host the WASM parser) |
| `map/static/map/map.css` | design tokens (colour/type/spacing/radius scales), the app-shell grid, and per-feature styling |
| `map/static/map/ui.css` + `ui.js` | the shared UI primitives every feature builds from: buttons, fields, dialogs, list rows, bars, toggles, and the Escape-layer stack. New chrome should reuse these rather than restyle its own |
| `map/static/map/icons/` | *(generated)* item/building icon PNGs |
| `rust_parser/core/` | `sav_core`: the save parser + map-payload builder (pure Rust, embeds the game-data tables) |
| `rust_parser/wasm/` | `sav_wasm`: the wasm-bindgen boundary the worker loads |
| `rust_parser/tauri/` | `sav_tauri`: native desktop shell (Tauri v2) over `sav_core`, mirrors the wasm binding |
| `game_data/` | extraction scripts + hand-curated game metadata (`categoryLabels.json`, `categoryOverrides.json`, `SCHEMA.md`) |
| `game_data/sav_data/` | *(committed)* static world tables (resource nodes, slugs, crash sites...) regenerated from the game's level data by `extractors/extract_collectables.py` |
| `game_data/` | extraction scripts, `SCHEMA.md`, and the folder rules in [`game_data/README.md`](game_data/README.md) |
| `game_data/curated/` | *(committed)* the hand-maintained inputs: name corrections, type paths, category labels/overrides, pickup item classes |
| `game_data/docs.json` | *(not committed)* the game's own data dump, input to `extract_docs_json.py` |
| `game_data/generated/` | *(generated)* item/building/recipe/schematic JSONs, creature spawner/name tables, `map_highres.png` |
| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `release.py` |
| `game_data/generated/docs/` | *(generated)* item/building/recipe/schematic/category/phase JSONs |
| `game_data/generated/world/` | *(generated)* level-export tables: resource nodes, slugs, somersloops, mercer spheres, crash sites, dropped items, creature spawners, caves, world bounds |
| `game_data/generated/` | *(generated)* `map_highres.png` + its tile pyramid |
| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `ui_shots.py` / `ui_behaviour.py` / `release.py` |
| `dist/` | *(generated)* the assembled static site |

Everything marked *(generated)* is git-ignored and produced by the steps
Expand All @@ -110,9 +114,26 @@ regression, needs `pip install playwright`) and the CI workflow in
`.github/workflows/ci.yml`, which runs the Rust suite and the wasm build on
every push to `main` and on pull requests.

Note: `sav_core` embeds `game_data/generated/*.json` and the icon manifest at
compile time, so building the Rust crates also requires the game data to be
extracted first.
The frontend has two browser-driven guards of its own — the chrome has no unit
tests, so these are what make a CSS or layout change verifiable:

```bash
py tools/ui_shots.py --serve --out ui_shots/before # record, then make changes
py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before
py tools/ui_behaviour.py --serve # dialogs, Escape layers, docks
```

`ui_shots.py` captures 17 UI states at three viewport widths and diffs them
pixel-wise against a previous run; `ui_behaviour.py` asserts the things a
screenshot cannot see (that dialogs are real modals, that the tooltip still
paints above one, that opening a category does not resize the map). Both run
against `dist/`, so build or copy the changed files there first.

Note: `sav_core` embeds `game_data/generated/{docs,world}/*.json` and the icon
manifest at compile time, and **nothing generated is committed**, so the Rust
crates do not build until you have either run `game_data/extract_all.py` or
unpacked `game_data.zip` (the build script says so if you forget). See
[`game_data/README.md`](game_data/README.md).

## Generating game data

Expand Down Expand Up @@ -149,10 +170,12 @@ the first which reads `game_data/docs.json`):

| Script | Output |
|---|---|
| `extract_docs_json.py` | `items`/`resources`/`buildings`/`recipes`/`buildingCategories`/`schematics.json` — see `game_data/SCHEMA.md` |
| `extract_docs_json.py` | `generated/docs/`: `items`/`resources`/`buildings`/`recipes`/`buildingCategories`/`schematics.json` — see `game_data/SCHEMA.md` |
| `extract_game_phases.py` | `gamePhases.json` (Space Elevator phase costs; optional, has a built-in fallback table) |
| `extract_spawners.py` | `creatureSpawners.json` (every creature spawner with position + creature class, e.g. all Lizard Doggo spawns) and `creatures.json` (official display name + icon per creature class, from the StringTables CSVs) |
| `extract_collectables.py` | the **committed** world tables in `game_data/sav_data/`: power slugs, somersloops, mercer spheres, crash sites (incl. their unlock cost/power requirements, derived from `mUnlockCost` + docs.json labels), free dropped items, resource purity — fully regenerated from the world cells (replacing the old GreyHak-derived tables, validated 1:1 against them; run after `extract_docs_json.py`). Pickup item classes are cooked into the actors but FModel can't decode that struct — they merge from the previous table, and `--items-from-save some.sav` reads them from a save if a game update adds pickups (the save must have physically visited them: saves only serialize actors whose world cell has streamed in near a player). Also writes `generated/consumables.json` (Paleberry/Beryl Nut/Bacon Agaric plants). Review its `sav_data/` git diff after a game update |
| `extract_spawners.py` | `generated/world/creatureSpawners.json` (every creature spawner with position + creature class, e.g. all Lizard Doggo spawns) and `creatures.json` (official display name + icon per creature class, from the StringTables CSVs) |
| `extract_collectables.py` | `generated/world/`: power slugs, somersloops, mercer spheres, crash sites (incl. their unlock cost/power requirements, derived from `mUnlockCost` + docs.json labels), free dropped items, resource purity, plus `consumables.json` (Paleberry/Beryl Nut/Bacon Agaric plants) — fully regenerated from the world cells (replacing the old GreyHak-derived tables, validated 1:1 against them; run after `extract_docs_json.py`). Pickup item classes are cooked into the actors but FModel can't decode that struct, so they come from the committed `curated/pickupItems.json`; `--items-from-save some.sav` learns new ones from a save and writes them back there (the save must have physically visited them: saves only serialize actors whose world cell has streamed in near a player) |
| `extract_world_bounds.py` | `generated/world/worldBounds.json`: the map's two invisible edges. The damaging perimeter comes from the `FGDamageOverTimeVolume` actors carrying a `/World/Hazard/WorldPerimeter/` DoT class — eleven walls (three of them rotated, cutting the NE/NW/SW corners) reduced to a 7-vertex safe-side polygon, plus the ceiling/floor slabs as altitudes. The water limit is the union of the 270 `FGWaterVolume` actors (all `mResourceClass` `Desc_Water_C`, i.e. swimmable and extractor-valid), which stops far inside the rendered ocean — 31 water-plane patches, the biggest 51 × 34 km |
| `extract_caves.py` | `generated/world/caves.json` — one outline polygon per cave system (~84), 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, some 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. Nothing in a save records a cave, so this is the only source |
| `extract_map_image.py` | `map_highres.png`, fused from the game's 4-corner sliced map render (`FactoryGame/Interface/UI/Assets/MapTest/SlicedMap`) |
| `copy_icons.py` | the icon PNGs under `map/static/map/icons/`, copied last — it reads the generated JSONs above to know which few hundred of the dump's tens of thousands of files are needed |

Expand All @@ -169,8 +192,9 @@ game files:
py game_data/package_game_data.py pack # writes game_data.zip in the repo root
```

The archive contains `game_data/generated/` (JSONs + map image) and
`map/static/map/icons/`. The recipient clones the repo and runs:
The archive contains `game_data/generated/` (the `docs/` and `world/` tables,
the map image and its tiles) and `map/static/map/icons/` — i.e. exactly what
`extract_all.py` produces and git ignores. The recipient clones the repo and runs:

```bash
py game_data/package_game_data.py unpack game_data.zip
Expand Down
13 changes: 9 additions & 4 deletions NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ A copy of the exchange is retained by the project author.

- The **Satisfactory Save Map** name, logo, and the `satisfactorymap.net`
domain (see the README's License & trademark section).
- Game-derived data (icons, map image, item/building tables): property of
Coffee Stain Studios, not distributed here. Satisfactory is a trademark of
Coffee Stain Studios; this project is not affiliated with or endorsed by
them.
- Game-derived data (icons, map image, item/building/world tables): property
of Coffee Stain Studios. None of it is in this repository -- everything
under `game_data/generated/` and `map/static/map/icons/` is produced from
your own copy of the game by `game_data/extract_all.py`, and is gitignored.
A prebuilt `game_data.zip` of exactly those outputs is attached to a release
as a convenience for building and for CI; it is the game's data, not this
project's, and it is offered on the same terms as any other extraction of
assets you already own. Satisfactory is a trademark of Coffee Stain Studios;
this project is not affiliated with or endorsed by them.
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ up to **15 seconds at a time** — WebGL rendering instead of DOM markers
- **The whole factory, mapped** — buildings by build-menu category,
belts/pipes/railways/power lines as curves, resource nodes with purity,
vehicles, trains, players, collectables and crash sites.
- **The world itself** — every **cave** on the map outlined with its name,
area and depth; the damaging **world border** and the altitudes it starts
at; and where the water you can actually swim in and pump from ends, which
is well inside the ocean the game draws. None of it shows on the in-game
map — it's traced from the game's own level data.
- **Find anything** — search across every inventory in the save ("where did
I leave my hard drives?"), per-building tooltips with recipe, power,
clock speed and belt/pipe **bottleneck detection**.
Expand Down Expand Up @@ -81,9 +86,9 @@ Everything — site, parser, desktop app, data extraction — is covered in
The Rust parser is a port of
[GreyHak/sat_sav_parse](https://github.com/GreyHak/sat_sav_parse), validated
field-by-field against the Python reference with bit-exact differential
gates. The static world tables in `game_data/sav_data/` were originally
converted from that project; they now regenerate from the game's own level
data (validated 1:1 against the originals — see
gates. The static world tables (resource nodes, slugs, crash sites…) were
originally converted from that project; they now regenerate from the game's
own level data (validated 1:1 against the originals — see
`game_data/extractors/extract_collectables.py`). This project wouldn't
exist without GreyHak's format work.

Expand All @@ -100,7 +105,9 @@ publish their source, including when only serving the app. The canonical
instance is [satisfactorymap.net](https://satisfactorymap.net/).

Satisfactory is a trademark of Coffee Stain Studios. The game-derived data
(icons, map image, item/building tables) belongs to Coffee Stain Studios and
is not distributed in this repository — see
[CONTRIBUTING.md](CONTRIBUTING.md). This project is not affiliated with or
endorsed by Coffee Stain Studios.
(icons, map image, item/building/world tables) belongs to Coffee Stain
Studios: none of it is committed here — it is extracted from your own copy of
the game by `game_data/extract_all.py`, or unpacked from the convenience
archive attached to a release (see [game_data/README.md](game_data/README.md)
and [CONTRIBUTING.md](CONTRIBUTING.md)). This project is not affiliated with
or endorsed by Coffee Stain Studios.
94 changes: 94 additions & 0 deletions docs/dock-map-anchoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Keeping the map still when a dock opens — two failed attempts

## The problem

The docks are columns of the app-shell grid, so opening one changes the map's
box. Leaflet's `invalidateSize()` preserves the map's **centre**, which is the
wrong invariant when the box changes from one side: opening the ~280px layers
dock moves the map's left edge right by 280 *and* shrinks its width by 280, and
holding the centre still splits the difference. Measured drift of a fixed world
point, in viewport pixels:

| Action | Drift |
| --- | --- |
| Show/hide the layers dock | ±141 px |
| Open/close a tool dock | ±161 px |

This is real and worth fixing. Two attempts were made and **both were
reverted**; `panels.js` is back to a plain `invalidateSize()` in a
rAF-coalesced ResizeObserver.

## Attempt 1 — compensate inside the ResizeObserver

Pin the world point at viewport (0, 0), call
`invalidateSize({pan: false, animate: false})`, measure where that point ended
up, `panBy` the difference. Done synchronously in the observer, which runs
after layout and before paint.

Geometrically correct — before/after drift went to 0 px. But the map still
visibly moved, because the object canvases are positioned relative to Leaflet's
map pane and repaint on the *next* frame via `_requestReset`'s rAF coalescing.
Re-anchoring the pane in one frame while the canvases catch up in the next
leaves a frame where every object is drawn offset from the tiles beneath it.

## Attempt 2 — make the whole transition atomic

Route every chrome mutation through a `withMapAnchored(mutate)` helper so the
resize, the compensation and a forced synchronous canvas repaint
(`MapApp.layer.resetNow()`, added to `map.js`) all happen in one task, and add
an idempotence guard so the helper and the ResizeObserver could not both
correct the same resize.

Reported as **worse than either the bug or attempt 1**. Two likely reasons,
neither of which the tests could see:

1. `resetNow()` forces a full synchronous redraw of every bucket in the click
handler. On a large save, with hardware acceleration off, that is a
main-thread stall where there used to be a coalesced repaint one frame
later. Trading a visual glitch for a freeze is a bad trade.
2. The drag path called it on every `pointermove`, i.e. a full redraw per
pointer event.

## Why the tests said it was fine

`tools/ui_behaviour.py` sampled `requestAnimationFrame` geometry in **headless**
Chrome against a small save. That measures whether the numbers line up. It does
not measure:

- how long the main thread is blocked (the actual regression in attempt 2),
- what is really painted, as opposed to what the DOM says between frames,
- software rendering, which is how the app runs on at least one real machine.

The lesson is not "add more geometry assertions". It is that this specific
problem cannot be validated headlessly: it needs a headed browser, a large
save, and a measurement of frame timing rather than element positions.

## Resolution — don't resize the map at all

There was no third attempt at compensating. The docks were changed to **overlay
the map** instead of taking grid columns: they are `position: fixed` against the
window's edges, and `#map` fills everything below the app bar at all times.

The map's box is now a function of the window alone, so a dock opening, closing
or being dragged never resizes it, Leaflet is never asked to re-fit it, and
there is nothing to re-centre. The bug is gone by construction rather than by
correction — no compensation code, no forced repaint, no extra work per toggle.

The docks still read as attached: flush to the edge, square, full height, one
border, opaque. What changed is only what is *behind* them.

Costs, accepted:

- A sliver of the world sits behind each dock. Panning reaches it, and closing
the docks reveals it with no movement at all.
- `#mapOverlays` (the layer holding the hint bars, the selection bar, the
active-filter banner) insets by `--dock-left-inset` / `--dock-right-inset` so
those still centre on the *visible* map rather than the full one. Those two
custom properties, and `body.has-rail` (set by altitude.js), exist only
because the layout no longer derives the dock widths from content.
- Leaflet's own controls live inside `#map`, so `.leaflet-left` / `.leaflet-right`
inset by the same values to stay clear of a dock.

`tools/ui_behaviour.py` now asserts the CAUSE rather than the symptom: across
five actions plus a width drag, sampled every animation frame, the map's
bounding box must not change and a pinned world point must not move.
Loading
Loading