docs+tool: locate & extract world factory production-line recipes
The fixed single-recipe world structures (conveyors, e.g. 1 Raw Aurogen Crystal -> 10 Energy Rods) are a separate mechanic from workbench recipes. Document where they live and how to read them, with a reproducible extractor. - docs/PRODUCTION_LINES.md: the 'where to find it' record — ECS classes (ProductionLineRecipeComponent -> CraftingRecipe), the epb_assets_all game_conveyor_*_epb prefabs, EntityBlueprint/Odin serialization, the extraction path, and the open energy_grid_*/island-placement lead. - bundle/extract_production_lines.py: UnityPy + odin_read extractor -> extracted/production_lines.json (14 conveyor recipes). - BUNDLES.md: add production_lines.json to the data-source map.
This commit is contained in:
127
bundle/extract_production_lines.py
Normal file
127
bundle/extract_production_lines.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user