Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ jobs:
path: |
game_data/generated
map/static/map/icons
key: game-data-v1
key: game-data-v2
- name: Fetch game data
# sav_core embeds game_data/generated/*.json at compile time.
if: steps.game-data.outputs.cache-hit != 'true'
run: |
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v1/game_data.zip
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/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 +59,11 @@ jobs:
path: |
game_data/generated
map/static/map/icons
key: game-data-v1
key: game-data-v2
- 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-v1/game_data.zip
wget -q -O game_data.zip https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ map image) — it's extracted from the game's own files and too
large/derivative to version. Get it one of two ways:

- **Quick setup — download the pre-extracted archive**:
[game_data.zip on Google Drive](https://drive.google.com/file/d/16JshnM65xrTpwxwbYs2iHmoog2AKDGZN/view?usp=sharing),
[game_data.zip from the latest game-data release](https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip)
(the same archive the production build downloads — see `build.sh`),
then unpack it —
```bash
py game_data/package_game_data.py unpack path/to/game_data.zip
Expand Down
4 changes: 2 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ set -euo pipefail
# fetching "latest" of an installer -- or an unverified game-data blob -- would
# let an upstream change (or compromise) flow straight into the deployed site.
WASM_PACK_VERSION="v0.15.0"
GAME_DATA_URL="https://github.com/valentinps/satisfactorymap/releases/download/game-data-v1/game_data.zip"
GAME_DATA_SHA256="75b2087fae82ff00a1d36fccebd0970550e5ccebfad6d2b159c2838718cd13d6"
GAME_DATA_URL="https://github.com/valentinps/satisfactorymap/releases/download/game-data-v2/game_data.zip"
GAME_DATA_SHA256="9db05102cf5ce375ddbb55d4c47cad07cc536603f490faa716e10ef693d07af6"

echo "Installing Rust..."
# rustup itself is fetched over pinned-TLS; the toolchain is stable (pin it
Expand Down
24 changes: 17 additions & 7 deletions docs/release-notes-0.1.7.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# v0.1.7

Load saves straight from a URL, sturdier sidebar filtering, consistent ore
labels — and under the hood, the map's world data now regenerates from the
game's own files.
Creature spawners on the map, a calmer first load, saves loadable straight
from a URL — and under the hood, the map's world data now regenerates from
the game's own files.

## New

- **Creature spawners on the map.** A new Spawners category pins every
creature spawn point on the planet — Hogs, Crab Hatchers, Stingers and
Spitters as toggleable families, plus Lizard Doggos, Flightless Birbs and
the Space Giraffe-Tick-Penguin-Whale Thing — about 2,800 pins, each with
the game's own creature icon. The data comes from the game's level files
(a save never records which creature a spawner spawns), so it's identical
on every save.
- **A calmer first load.** The world layers — resource nodes, resource
wells, collectables, spawners, and dropped items — now start hidden, so
loading a save shows your factory instead of thousands of pins. Switching
any of them on is remembered like every other filter choice.
- **Load a save from a URL.** Append `?url=<address-of-a-save>` to the map's
address and the file downloads straight into your browser (with progress)
and parses as usual — so a dedicated server's autosave can be shared as a
Expand Down Expand Up @@ -40,10 +51,9 @@ game's own files.
sites, dropped items, resource-node purity) now regenerate from the
game's own level data via a one-command pipeline — validated 1:1 against
the previous tables, and recovering two dropped-item pickups and one
resource node the old data missed. New groundwork tables extracted the
same way: every creature spawner on the map (all 159 Lizard Doggo spawns
included), crab hatcher locations, consumable plants, and the official
creature names with icons.
resource node the old data missed. Extracted the same way: the spawner
and creature-name/icon tables behind the new Spawners category (all 159
Lizard Doggo spawns included), plus consumable plants as groundwork.

## Checksums (SHA-256)

Expand Down
9 changes: 6 additions & 3 deletions game_data/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,12 @@ Hatcher variants (world actors, not spawner-spawned).
path tree, so it lands in the same `<Content>` layout).
- Creatures are NOT in Docs.json at all (they have no item/build
descriptor) -- hence the string-table route. These are the game's literal
English strings, quirks included:
the Space Giraffe's real in-game name is "Unknown File Error #6265616e",
and the Red Forest Spitter variants share display names with the normal
English strings, quirks included -- with one deliberate exception: the
Space Giraffe's real in-game name is "Unknown File Error #6265616e" (hex
for "bean"), which on a map layer reads as the app failing to resolve a
name, so the extractor's `DISPLAY_NAME_OVERRIDES` replaces it with the
community name "Space Giraffe-Tick-Penguin-Whale Thing". Also:
the Red Forest Spitter variants share display names with the normal
Forest ones (their descriptors point at the same localization keys). Two
classes have no descriptor and no localization entry -- genuinely
unnamed in-game -- so their names are this repo's own fallbacks:
Expand Down
11 changes: 11 additions & 0 deletions game_data/extractors/extract_spawners.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@
"/FactoryGame/Character/Creature/CreatureDescriptors/UI/IconDesc_Hatcher_256"),
}

# Applied over the game's own strings after extraction. The Space Giraffe's
# literal in-game name is "Unknown File Error #6265616e" (the hex spells
# "bean") -- a great gag in-game, but on a map layer it reads as this app
# actually failing to resolve a name, so use the community name instead.
DISPLAY_NAME_OVERRIDES = {
"Char_SpaceGiraffe_C": "Space Giraffe-Tick-Penguin-Whale Thing",
}


def shortClassName(objectField):
"""{"ObjectName": "BlueprintGeneratedClass'Char_Hog_C'"} -> "Char_Hog_C"."""
Expand Down Expand Up @@ -253,6 +261,9 @@ def main():
if creature not in creatures and creature != "unknown":
print(f"WARNING: spawned creature {creature} has no descriptor and no "
f"fallback display name")
for creature, displayName in DISPLAY_NAME_OVERRIDES.items():
if creature in creatures:
creatures[creature]["displayName"] = displayName
creatures = dict(sorted(creatures.items()))
with open(NAMES_OUTPUT_PATH, "w", encoding="utf-8", newline="\n") as f:
json.dump(creatures, f, ensure_ascii=False, indent=1)
Expand Down
103 changes: 102 additions & 1 deletion map/static/map/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// + game_data/categoryLabels.json via payload.menuOrder -- see
// buildBuildingCategorySections. Resource Nodes, HUB, Entities, Collectables
// (which nests Hard Drives alongside Power Slugs/Somersloops/Mercer Spheres),
// and Dropped Items (loose ground stacks) are their own separate sections. renderGroup() supports the
// Spawners (creature spawn markers, grouped by family), and Dropped Items
// (loose ground stacks) are their own separate sections. renderGroup() supports the
// subcategory nesting generically (see its doc comment).

var Filters = {};
Expand Down Expand Up @@ -244,13 +245,35 @@ var Filters = {};
return title.replace(/\s*\([\d,\/]+\)\s*$/, "");
}

// Top-level categories hidden unless explicitly enabled: the world layers
// -- every resource node/well, every collectable, every creature spawner,
// every loose ground stack -- are thousands of pins that buried the
// player's own factory.
// An explicit choice scoped to the category still wins and persists (a
// per-bucket entry, or a stored group toggle at any level inside it), but
// Check-all's blanket "*" entry deliberately does NOT reach these: it
// predates whatever category was added since (or was stored to mean "my
// factory", not "the whole planet"), and letting it through re-buried the
// map on the next load. Check all still shows everything for the current
// session -- it flips the buckets directly, this only governs restore.
var DEFAULT_HIDDEN_CATEGORIES = {
"Resource Nodes": true,
"Resource Wells": true,
"Collectables": true,
"Spawners": true,
"Dropped Items": true,
};

function savedGroupStateForStack() {
for (var i = groupPathStack.length; i > 0; i--) {
var key = "group:" + groupPathStack.slice(0, i).join("/");
if (savedVisibility.hasOwnProperty(key)) {
return savedVisibility[key];
}
}
if (DEFAULT_HIDDEN_CATEGORIES[groupPathStack[0]]) {
return false;
}
if (savedVisibility.hasOwnProperty("*")) {
return savedVisibility["*"];
}
Expand Down Expand Up @@ -1000,6 +1023,83 @@ var Filters = {};
});
}

// ---- Spawners (creature spawn markers -- static world data) --------------

// payload.spawners (see the rust core's collect_spawners) is the same for
// every save: creature spawn markers from the cooked level data, not save
// actors -- the save's own spawner actors never say which creature they
// spawn. Pins follow the collectables' design (real icon on a white pin
// circle), using the extracted creature icons keyed by class name
// (icons/creatures/<Char_*_C>.png -- see game_data/extractors/copy_icons.py).
var SPAWNER_ICON_BASE = "icons/creatures/";

// Creature families with several variants each get a collapsible subgroup
// (matched on the class name); everything else (Lizard Doggo, Flightless
// Birb, the Space Giraffe-Tick-Penguin-Whale Thing) is a loose row after
// them. First match wins; the patterns are mutually exclusive today.
var SPAWNER_FAMILIES = [
{ title: "Hogs", pattern: /Hog/ },
{ title: "Crab Hatchers", pattern: /Hatcher/ },
{ title: "Stingers", pattern: /Stinger/ },
{ title: "Spitters", pattern: /Spitter/ },
];

function buildSpawnersSection(navList, detailPane, payload) {
var spawnerTypes = payload.spawners || [];
var rowsByFamily = SPAWNER_FAMILIES.map(function() { return []; });
var looseRows = [];
var total = 0;
spawnerTypes.forEach(function(spawnerType) {
var count = pointCount(spawnerType.points, 3);
if (count === 0) {
return;
}
total += count;
var url = SPAWNER_ICON_BASE + encodeURIComponent(spawnerType.typePath) + ".png";
// The bucket label says "Spawner" so a rectangle-selection list can't
// read as live creatures; the sidebar row shows just the species
// (displayLabel), already sitting under the "Spawners" heading.
var label = spawnerType.label + " Spawner";
var tooltipInfo = function(index) {
return { title: label, rows: [], position: worldPositionAt(spawnerType.points, index) };
};
var bucket = makeIconBucket("spawner:" + spawnerType.typePath, label, CREATURE_COLOR,
spawnerType.points, spawnerType.ids, "static", tooltipInfo, url, 1);
var row = { label: label, displayLabel: spawnerType.label, count: count,
color: CREATURE_COLOR, buckets: [bucket], iconUrl: url };
for (var i = 0; i < SPAWNER_FAMILIES.length; i++) {
if (SPAWNER_FAMILIES[i].pattern.test(spawnerType.typePath)) {
rowsByFamily[i].push(row);
return;
}
}
looseRows.push(row);
});
if (total === 0) {
return;
}
renderTopLevelCategory(navList, detailPane, "Spawners (" + total + ")", "icon", CREATURE_COLOR, function(childrenDiv) {
var checkboxes = [];
var allBuckets = [];
SPAWNER_FAMILIES.forEach(function(family, familyIndex) {
var rows = rowsByFamily[familyIndex];
if (rows.length === 0) {
return;
}
var familyTotal = rows.reduce(function(s, r) { return s + r.count; }, 0);
var result = renderGroup(childrenDiv, family.title + " (" + familyTotal + ")", "icon",
CREATURE_COLOR, rows, { startCollapsed: true, iconUrl: rows[0].iconUrl });
checkboxes.push(result.checkbox);
allBuckets = allBuckets.concat(result.buckets);
});
looseRows.forEach(function(row) {
checkboxes.push(appendLeafRow(childrenDiv, row, "icon", CREATURE_COLOR));
allBuckets = allBuckets.concat(row.buckets);
});
return { buckets: allBuckets, checkboxes: checkboxes };
}, { iconUrl: CREATURE_ICON_URL });
}

// ---- Dropped / ground items ----------------------------------------------

// Fallback dot color for the rare dropped item whose ClassName has no
Expand Down Expand Up @@ -1490,6 +1590,7 @@ var Filters = {};
buildHubSection(navList, detailPane, payload);
buildEntitiesSection(navList, detailPane, payload);
buildCollectablesSection(navList, detailPane, payload);
buildSpawnersSection(navList, detailPane, payload);
buildDroppedItemsSection(navList, detailPane, payload);

// Fit the nav panel to the category labels now that they all exist.
Expand Down
29 changes: 29 additions & 0 deletions rust_parser/core/src/gamedata/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ pub struct PowerSlugs {
pub purple: SlugMap,
}

/// creatures.json: official display name + icon per creature class (see
/// game_data/extractors/extract_spawners.py).
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatureInfo {
pub display_name: String,
pub icon: Option<String>,
}

/// creatureSpawners.json: {creatureShortClass|"unknown": {instancePathName: [x,y,z]}}
pub type CreatureSpawnerMap = IndexMap<String, IndexMap<String, [f64; 3]>>;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TypePaths {
Expand Down Expand Up @@ -67,6 +79,10 @@ pub struct GameData {
pub recipes: serde_json::Map<String, serde_json::Value>,
pub schematics: serde_json::Map<String, serde_json::Value>,
pub game_phases: serde_json::Map<String, serde_json::Value>,
// -- game_data/generated/ (FModel level-export extracts; see
// game_data/extractors/extract_spawners.py) --
pub creatures: IndexMap<String, CreatureInfo>,
pub creature_spawners: CreatureSpawnerMap,
}

macro_rules! embed {
Expand Down Expand Up @@ -108,6 +124,11 @@ pub fn get() -> &'static GameData {
recipes: parse("recipes.json", embed!("generated/recipes.json")),
schematics: parse("schematics.json", embed!("generated/schematics.json")),
game_phases: parse("gamePhases.json", embed!("generated/gamePhases.json")),
creatures: parse("creatures.json", embed!("generated/creatures.json")),
creature_spawners: parse(
"creatureSpawners.json",
embed!("generated/creatureSpawners.json"),
),
})
}

Expand Down Expand Up @@ -156,6 +177,14 @@ mod tests {
assert!(d.recipes.len() > 300);
assert!(d.schematics.len() > 200);
assert!(!d.game_phases.is_empty());
assert!(d.creatures.len() >= 20, "creatures {}", d.creatures.len());
let spawner_total: usize = d.creature_spawners.values().map(|m| m.len()).sum();
assert!(spawner_total > 2000, "creature spawners {spawner_total}");
// Every spawner class has a name row (the extractor warns otherwise).
assert!(d
.creature_spawners
.keys()
.all(|c| c == "unknown" || d.creatures.contains_key(c)));
// Purity names are enum names.
let (_, purity, _, _) = d.resource_purity.values().next().unwrap();
assert!(["UNKNOWN", "IMPURE", "NORMAL", "PURE"].contains(&purity.as_str()));
Expand Down
10 changes: 7 additions & 3 deletions rust_parser/core/src/mapdata/collectors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! The 17 payload collectors, one module per _payloadSteps entry. Each is an
//! exact behavioral port of its Python namesake in map/sav_map_data.py.
//! The 18 payload collectors, one module per _payloadSteps entry. Each is an
//! exact behavioral port of its Python namesake in map/sav_map_data.py --
//! except "spawners", which post-dates the Python implementation (static
//! world data, no save input; see simple::collect_spawners).

pub mod buildings;
pub mod lines;
Expand All @@ -24,6 +26,7 @@ pub fn registry() -> Vec<(&'static str, Collector)> {
("droppedItems", world::collect_dropped_items),
("players", simple::collect_players),
("creatures", simple::collect_creatures),
("spawners", simple::collect_spawners),
("hub", simple::collect_hub),
("gameSettings", simple::collect_game_settings),
("vehicles", simple::collect_vehicles),
Expand All @@ -39,13 +42,14 @@ pub fn registry() -> Vec<(&'static str, Collector)> {

/// _payloadSteps key order -- payload keys must appear in this order
/// regardless of which subset is requested.
pub const STEP_ORDER: [&str; 17] = [
pub const STEP_ORDER: [&str; 18] = [
"buildingCategories",
"resourceNodes",
"collectables",
"hardDrives",
"players",
"creatures",
"spawners",
"vehicles",
"trains",
"droppedItems",
Expand Down
Loading
Loading