diff --git a/BUNDLES.md b/BUNDLES.md index 8aeeb40..09fb6ee 100644 --- a/BUNDLES.md +++ b/BUNDLES.md @@ -11,7 +11,8 @@ Read the bundles in Python with `bundle/extract_data.py` / `loot_probe.py` (Unit | `extracted/` output | Source bundle | Asset | |---|---|---| | `items_registry.json` / `item_definitions.json` | `configuration` | `CheatItemDefinitionsData.asset` | -| `crafting_recipes.json` | `craftingrecipes` | `Recipes_*` | +| `crafting_recipes.json` | `craftingrecipes` | `Recipes_*` (workbench) | +| `production_lines.json` | `epb` | `game_conveyor_*_epb` (world factories) — see [docs/PRODUCTION_LINES.md](docs/PRODUCTION_LINES.md) | | `loot_tables.json` | `configuration` | `conf_worldLootTables{Storm,Voyage}Config` (Odin-binary) | | `CompartmentsDatabase.json` | `walkershared` | `CompartmentsDatabase.json` (TextAsset) | | `epb_catalog.json` | `epb` | `*_epb.prefab` | diff --git a/bundle/extract_production_lines.py b/bundle/extract_production_lines.py new file mode 100644 index 0000000..1a2b438 --- /dev/null +++ b/bundle/extract_production_lines.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Extract SAND world 'Factory Production Line' (conveyor) recipes. + +These are the fixed single-recipe world structures (e.g. 1 Raw Aurogen Crystal -> +10 Energy Rods), DISTINCT from the player workbench recipes in +extracted/crafting_recipes.json. Same data shape, different home: + + Mechanic .... a "Factory Production Line": an item conveyor that runs ONE recipe. + ECS ......... Hologryph.Sand.Shared.Game.Features.Crafting + ProductionLineComponent (input/output entity indices) + ProductionLineRecipeComponent -> CraftingRecipe recipe + ProductionLine{Input,Output}DataComponent (isLargeItem flags) + CraftingRecipe = { CraftingIngredient[] inputIngredients, + CraftingIngredient[] outputIngredients, + float craftingTimeSeconds } + CraftingIngredient = { string itemId, int amount } + Where ....... epb_assets_all.bundle, prefabs named game_conveyor_*_epb.prefab + (Assets/Content/Game/game_conveyor/...). Each prefab GameObject has + an EntityBlueprint (Sirenix Odin SerializedMonoBehaviour) component; + the recipe lives in its Odin-serialized `serializationData`. + Islands ..... energy_grid_*_epb.prefab (Wunderinsel, Kaiserplatz, DeusExMachine, + LittleFactory1-3, LittleFactoryArmory*) place/group conveyors per + location. They reference conveyors by entity ref, NOT item-id string, + so recipe->island mapping needs the island prefab placements + (islands_assets_all.bundle) -- not done here. + +Extraction path: UnityPy (typetree via IL2CPP generator) reads each EntityBlueprint +MonoBehaviour -> serializationData.SerializedBytes -> odin_read.parse -> walk for +the CraftingRecipe node. Mirrors extract_loot.py (loot tables are Odin too). + +Writes extracted/production_lines.json. +""" +import os, sys, json, UnityPy +from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import odin_read + +GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" +META = os.path.join(GAME, "Sand_Data/il2cpp_data/Metadata/global-metadata.dat") +DLL = os.path.join(GAME, "GameAssembly.dll") +BUNDLES = "/home/downloadpizza/sand_tools/bundles" # symlink -> StreamingAssets/aa/... +OUT = "/home/downloadpizza/sand_tools/extracted/production_lines.json" +UNITY = "6000.0.40f1" + + +def recipes_in(node, out): + """Collect every CraftingRecipe-shaped dict in an odin_read tree.""" + if isinstance(node, dict): + if "inputIngredients" in node or "outputIngredients" in node: + out.append(node) + for v in node.values(): + recipes_in(v, out) + elif isinstance(node, list): + for v in node: + recipes_in(v, out) + + +def ingredients(arr): + """Flatten an Odin CraftingIngredient[] (possibly nested in $items) -> [(itemId, amount)].""" + if isinstance(arr, dict): + arr = arr.get("$items", arr) + out = [] + for x in (arr or []): + rows = x if isinstance(x, list) else [x] + for y in rows: + if isinstance(y, dict) and "itemId" in y: + out.append((y.get("itemId"), y.get("amount"))) + return out + + +def entity_blueprint(go): + """Return the EntityBlueprint MonoBehaviour object_reader on a prefab GameObject, or None.""" + for c in go.m_Components: + co = c.read() + r = co.object_reader + if r.type.name == "MonoBehaviour" and co.m_Script.read().m_ClassName == "EntityBlueprint": + return r + return None + + +def main(): + gen = TypeTreeGenerator(UNITY) + gen.load_il2cpp(open(DLL, "rb").read(), open(META, "rb").read()) + env = UnityPy.load(os.path.join(BUNDLES, "epb_assets_all.bundle"), + os.path.join(BUNDLES, "sand_monoscripts.bundle")) + + result = {} + for path, obj in env.container.items(): + base = path.split("/")[-1].replace("_epb.prefab", "") + if not base.startswith("game_conveyor_") or obj.type.name != "GameObject": + continue + eb = entity_blueprint(obj.read()) + if eb is None: + continue + script = eb.read().m_Script.read() + full = (script.m_Namespace + "." if script.m_Namespace else "") + script.m_ClassName + nodes = json.loads(gen.get_nodes_as_json(script.m_AssemblyName, full)) + sb = eb.read_typetree(nodes).get("serializationData", {}).get("SerializedBytes") + if not sb: + continue + parsed = odin_read.parse(bytes(sb)) + found = [] + recipes_in(parsed["roots"], found) + recipes_in(parsed["items"], found) + for r in found: + result[base] = { + "inputs": [{"itemId": i, "amount": a} for i, a in ingredients(r.get("inputIngredients"))], + "outputs": [{"itemId": i, "amount": a} for i, a in ingredients(r.get("outputIngredients"))], + "craftTimeSeconds": r.get("craftingTimeSeconds"), + } + + out = { + "_source": "epb_assets_all.bundle game_conveyor_*_epb EntityBlueprint " + "(Odin) -> ProductionLineRecipeComponent.recipe (CraftingRecipe)", + "_count": len(result), + "production_lines": dict(sorted(result.items())), + } + json.dump(out, open(OUT, "w"), indent=1) + print(f"wrote {OUT} ({len(result)} conveyor recipes)") + for k, r in out["production_lines"].items(): + inp = " + ".join(f"{i['amount']}x {i['itemId']}" for i in r["inputs"]) or "?" + outp = " + ".join(f"{i['amount']}x {i['itemId']}" for i in r["outputs"]) or "?" + print(f" {k.replace('game_conveyor_',''):24s} {inp:48s} -> {outp} ({r['craftTimeSeconds']}s)") + + +if __name__ == "__main__": + main() diff --git a/docs/PRODUCTION_LINES.md b/docs/PRODUCTION_LINES.md new file mode 100644 index 0000000..f08565d --- /dev/null +++ b/docs/PRODUCTION_LINES.md @@ -0,0 +1,70 @@ +# World factory "production lines" (conveyors) — where to find them + +The game has **fixed single-recipe world structures** — item conveyors that run *one* +recipe (e.g. **1 Raw Aurogen Crystal → 10 Energy Rods**). These are a separate mechanic +from the player **workbench** recipes (which live in `extracted/crafting_recipes.json`, +sourced from `CraftingRecipeBundle` assets). This note records **where they live** so the +data can be re-derived after a game update. + +## The mechanic + +In code it's a *"Factory Production Line"*. It's an Entitas ECS feature, namespace +`Hologryph.Sand.Shared.Game.Features.Crafting` (see `il2cpp/dump.cs`): + +| Class | Holds | +|---|---| +| `ProductionLineComponent` | `int inputEntityIndex`, `int outputEntityIndex` | +| `ProductionLineRecipeComponent` | `CraftingRecipe recipe` ← the recipe | +| `ProductionLineInputDataComponent` / `…OutputDataComponent` | `bool isLargeItem{Input,Output}` | +| `CraftingRecipe` | `CraftingIngredient[] inputIngredients`, `CraftingIngredient[] outputIngredients`, `float craftingTimeSeconds` | +| `CraftingIngredient` | `string itemId`, `int amount` | + +Note `CraftingRecipe` / `CraftingIngredient` are the **same types** the workbench uses — +only the container differs (a workbench holds `List recipeBundles`; +a production line holds a single `CraftingRecipe` on the entity). + +## Where the data lives + +- **Bundle:** `epb_assets_all.bundle` (reachable via the `bundles/` symlink). +- **Prefabs:** `game_conveyor_*_epb.prefab`, under + `Assets/Content/Game/game_conveyor/...`. 14 carry a real recipe; the + `game_conveyor_base*` ones are template/test conversions (cf. `TestRecipesBundle`). +- **Serialization:** each prefab GameObject (`m_Name = game_conveyor__epb`) has two + components — a `Transform` and a `MonoBehaviour` whose script is **`EntityBlueprint`**. + `EntityBlueprint : SerializedMonoBehaviour` (Sirenix Odin), so the component data — + including the `CraftingRecipe` — is in its **Odin-serialized + `serializationData.SerializedBytes`**, not in plain typetree fields. (Same situation as + the loot tables — see [TASK.md](TASK.md) / `extract_loot.py`.) + +### Extraction path (how to read it) + +`UnityPy` (with an IL2CPP `TypeTreeGenerator` over `GameAssembly.dll` + +`global-metadata.dat`) reads the `EntityBlueprint` MonoBehaviour → pull +`serializationData.SerializedBytes` → `bundle/odin_read.py` parses the Odin binary → +walk the tree for the node with `inputIngredients` / `outputIngredients`. + +```bash +venv/bin/python bundle/extract_production_lines.py # -> extracted/production_lines.json +``` + +Key item ids seen here: `item_crystalHandles` = *Raw Aurogen Crystal*, +`item_energyBar` = *NZ Mk2 Energy Rod*, `item_blackBox` = *Black Box*, +`item_resourceCoralPiece` = *Coral Chunk*. Resolve any id → display name via +`extracted/items_registry.json` / `extracted/item_names.json` (built from +`CheatItemDefinitionsData`). + +## Island placement — open lead + +Which factory/island runs which conveyor is grouped by the +`energy_grid_*_epb.prefab` blueprints in the same bundle: +`Wunderinsel`, `Kaiserplatz`, `DeusExMachine`, `Factorio`, `LittleFactory1-3`, +`LittleFactoryArmory1-3`, `TestFactory`. The **`…Armory`** groups are the likely +**Sprengstofffabrik** (explosives factory) host for the explosive / grenade / rocket / +cannon conveyors. + +**Not yet resolved:** an `energy_grid_*` blueprint's Odin data references its conveyors by +**entity reference, not item-id string** (a string scan of its serialized bytes finds no +`game_conveyor_*` / `item_*` ids). So mapping recipe → specific island needs the **island +prefab placements** in `islands_assets_all.bundle` (the `island_*` / fort / `loc_event_*` +prefabs, where `Sprengstofffabrik` is an i2 `Toponyms/` name), not the conveyor or +energy_grid EPBs alone. That linkage is the next step if island attribution is wanted. diff --git a/extracted/production_lines.json b/extracted/production_lines.json new file mode 100644 index 0000000..6b1bcb0 --- /dev/null +++ b/extracted/production_lines.json @@ -0,0 +1,260 @@ +{ + "_source": "epb_assets_all.bundle game_conveyor_*_epb EntityBlueprint (Odin) -> ProductionLineRecipeComponent.recipe (CraftingRecipe)", + "_count": 14, + "production_lines": { + "game_conveyor_40mmT3Cannon": { + "inputs": [ + { + "itemId": "item_alloySteel", + "amount": 40 + }, + { + "itemId": "item_resourceMetal_t2", + "amount": 300 + } + ], + "outputs": [ + { + "itemId": "game_packedAutoTurretT3Container", + "amount": 1 + } + ], + "craftTimeSeconds": 55.0 + }, + "game_conveyor_70mmT3Cannon": { + "inputs": [ + { + "itemId": "item_alloySteel", + "amount": 40 + }, + { + "itemId": "item_resourceMetal_t2", + "amount": 300 + } + ], + "outputs": [ + { + "itemId": "game_packedShotgunTurretT3Container", + "amount": 1 + } + ], + "craftTimeSeconds": 55.0 + }, + "game_conveyor_80mmT3Cannon": { + "inputs": [ + { + "itemId": "item_alloySteel", + "amount": 40 + }, + { + "itemId": "item_resourceMetal_t2", + "amount": 300 + } + ], + "outputs": [ + { + "itemId": "game_packedTurretT3Container", + "amount": 1 + } + ], + "craftTimeSeconds": 55.0 + }, + "game_conveyor_armorPiercingRockets": { + "inputs": [ + { + "itemId": "item_resourceFabric", + "amount": 10 + }, + { + "itemId": "item_resourceGunpowder", + "amount": 10 + } + ], + "outputs": [ + { + "itemId": "item_rocketLauncherAmmoArmorPiercing", + "amount": 3 + } + ], + "craftTimeSeconds": 5.0 + }, + "game_conveyor_baseInventoryToInventory": { + "inputs": [ + { + "itemId": "item_resourceMetal_t1", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_c4Dynamite", + "amount": 1 + }, + { + "itemId": "item_resourceMetal_t1", + "amount": 2 + } + ], + "craftTimeSeconds": 5.0 + }, + "game_conveyor_baseInventoryToLarge": { + "inputs": [ + { + "itemId": "item_resourceMetal_t1", + "amount": 1 + }, + { + "itemId": "item_c4Dynamite", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_explosiveBig", + "amount": 1 + } + ], + "craftTimeSeconds": 15.0 + }, + "game_conveyor_baseLargeToInventory": { + "inputs": [ + { + "itemId": "item_wineBox", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_coinCrown", + "amount": 1000 + } + ], + "craftTimeSeconds": 15.0 + }, + "game_conveyor_baseLargeToLarge": { + "inputs": [ + { + "itemId": "item_wineBox", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_explosiveBig", + "amount": 1 + } + ], + "craftTimeSeconds": 15.0 + }, + "game_conveyor_computingModules": { + "inputs": [ + { + "itemId": "item_blackBox", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_resourceMetal_t3", + "amount": 10 + } + ], + "craftTimeSeconds": 7.0 + }, + "game_conveyor_contactGrenades": { + "inputs": [ + { + "itemId": "item_resourceFabric", + "amount": 10 + }, + { + "itemId": "item_resourceGunpowder", + "amount": 10 + } + ], + "outputs": [ + { + "itemId": "item_grenadeContact", + "amount": 5 + } + ], + "craftTimeSeconds": 5.0 + }, + "game_conveyor_coralDust": { + "inputs": [ + { + "itemId": "item_resourceCoralPiece", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_resourceCoralDust", + "amount": 10 + }, + { + "itemId": "item_resourceMetal_t1", + "amount": 2 + } + ], + "craftTimeSeconds": 5.0 + }, + "game_conveyor_energyRods": { + "inputs": [ + { + "itemId": "item_crystalHandles", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_energyBar", + "amount": 10 + } + ], + "craftTimeSeconds": 7.0 + }, + "game_conveyor_explosiveSmall": { + "inputs": [ + { + "itemId": "item_resourceFabric", + "amount": 5 + }, + { + "itemId": "item_resourceGunpowder", + "amount": 5 + } + ], + "outputs": [ + { + "itemId": "item_c4Dynamite", + "amount": 5 + }, + { + "itemId": "item_resourceMetal_t1", + "amount": 2 + } + ], + "craftTimeSeconds": 5.0 + }, + "game_conveyor_mechanicalParts": { + "inputs": [ + { + "itemId": "item_resourceMetalParts", + "amount": 1 + } + ], + "outputs": [ + { + "itemId": "item_resourceMetal_t1", + "amount": 2 + }, + { + "itemId": "item_resourceMetal_t1", + "amount": 2 + } + ], + "craftTimeSeconds": 1.0 + } + } +} \ No newline at end of file