diff --git a/bundle/discord_recipes.py b/bundle/discord_recipes.py new file mode 100644 index 0000000..d08bbd8 --- /dev/null +++ b/bundle/discord_recipes.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Emit Discord-ready (monospace code-block) tables of all craftable recipes: +the workbench recipes (T1 + T2, both on the Trampler; T2 also at world workbenches) +and the world conveyor production lines with their island locations. +""" +import json, collections + +NAMES = json.load(open("extracted/item_names.json"))["items"] +REC = json.load(open("extracted/crafting_recipes.json"))["recipes"] +PL = json.load(open("extracted/production_lines.json"))["production_lines"] +PLACE = json.load(open("extracted/conveyor_placements.json")) +ISL = json.load(open("extracted/island_names.json"))["islands"] + + +def nm(item_id): + e = NAMES.get(item_id) + if e and e.get("name"): + return e["name"] + return item_id # fall back to raw id (no i2 term) + + +def fmt_side(ings): + # merge duplicate item slots (some recipes list the same id in two output slots) + merged = {} + order = [] + for i in ings: + k = i["itemId"] + if k not in merged: + merged[k] = 0 + order.append(k) + merged[k] += i["amount"] + return " + ".join("%d %s" % (merged[k], nm(k)) for k in order) + + +def table(rows, headers): + cols = list(zip(*([headers] + rows))) if rows else [[h] for h in headers] + w = [max(len(str(c)) for c in col) for col in cols] + line = lambda r: "| " + " | ".join(str(c).ljust(w[i]) for i, c in enumerate(r)) + " |" + sep = "|" + "|".join("-" * (w[i] + 2) for i in range(len(headers))) + "|" + out = [line(headers), sep] + [line(r) for r in rows] + return "\n".join(out) + + +def recipe_rows(keys): + rows = [] + for key in keys: + for r in REC[key]: + rows.append([fmt_side(r["inputs"]), fmt_side(r["outputs"]), "%gs" % r["craftTimeSeconds"]]) + return rows + + +# ---- island placement map: conveyor base name -> [in-game island names] ---- +def island_disp(prefab): + d = ISL.get(prefab, {}) + return d.get("toponym") or prefab.replace("island_", "") + + +conv_to_islands = collections.defaultdict(list) +for prefab, d in PLACE["islands"].items(): + if prefab.startswith("island_test"): + continue + for rc in d["recipes"]: + conv_to_islands[rc["conveyor"]].append(island_disp(prefab)) + for u in d["product_conveyors_without_matching_recipe"]: + # name-mismatch: singular placed instance maps to its plural recipe key + conv_to_islands[u["conveyor"] + "s"].append(island_disp(prefab)) + +OUT = [] +OUT.append("## Workbench Crafts\n") + +for title, keys in [("Tier 1", ["Recipes_Utility_Workbench_T1", "Recipes_Armament_Workbench_T1"]), + ("Tier 2", ["Recipes_Armament_Workbench_T2"])]: + OUT.append("**%s**" % title) + OUT.append("```") + OUT.append(table(recipe_rows(keys), ["Inputs", "Output", "Time"])) + OUT.append("```") + +OUT.append("\n## World Conveyor Production Lines (single-recipe, fixed locations)\n") +# the game_conveyor_base* lines are test-island stubs (placeholder recipes); skip them +rows = [] +for conv, r in sorted(PL.items()): + if conv.startswith("game_conveyor_base"): + continue + islands = conv_to_islands.get(conv, []) + loc = ", ".join(sorted(set(islands))) if islands else "(defined, not placed)" + rows.append([fmt_side(r["inputs"]), fmt_side(r["outputs"]), "%gs" % r["craftTimeSeconds"], loc]) +OUT.append("```") +OUT.append(table(rows, ["Inputs", "Output", "Time", "Location (island)"])) +OUT.append("```") + +print("\n".join(OUT)) diff --git a/bundle/workbench_bundles.py b/bundle/workbench_bundles.py new file mode 100644 index 0000000..e924a32 --- /dev/null +++ b/bundle/workbench_bundles.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""For every EntityBlueprint carrying a CraftingWorkbenchDataComponent, list the +CraftingRecipeBundle assets it references (recipeBundles). Resolves the Odin +ext_index refs against the blueprint's serializationData.ReferencedUnityObjects +(PPtrs) -> CraftingRecipeBundle MonoBehaviour -> m_Name. +""" +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" +UNITY = "6000.0.40f1" +WB = "CraftingWorkbenchDataComponent" + + +def find_recipe_indices(node, out): + """Find a node of $type CraftingWorkbenchDataComponent and collect ext_index refs.""" + if isinstance(node, dict): + if node.get("$type", "").endswith("CraftingWorkbenchDataComponent"): + def grab(n): + if isinstance(n, odin_read.Ref): + if n.kind == "ext_index": + out.append(n.val) + elif isinstance(n, dict): + for v in n.values(): + grab(v) + elif isinstance(n, list): + for v in n: + grab(v) + grab(node) + for v in node.values(): + find_recipe_indices(v, out) + elif isinstance(node, list): + for v in node: + find_recipe_indices(v, out) + + +def main(): + gen = TypeTreeGenerator(UNITY) + gen.load_il2cpp(open(DLL, "rb").read(), open(META, "rb").read()) + # load the workbench prefabs (epb), the recipe-bundle assets they reference + # (craftingrecipes), and monoscripts so m_Script resolves + paths = [os.path.join(BUNDLES, b) for b in + ("epb_assets_all.bundle", "craftingrecipes_assets_all.bundle", + "sand_monoscripts.bundle")] + env = UnityPy.load(*paths) + nodecache = {} + + # path_id -> CraftingRecipeBundle name, to resolve the workbench's ext refs + bundle_names = {} + for o in env.objects: + if o.type.name == "MonoBehaviour": + try: + d = o.read() + if d.m_Script.read().m_ClassName == "CraftingRecipeBundle": + bundle_names[o.path_id] = d.m_Name + except Exception: + pass + + for path, obj in env.container.items(): + if obj.type.name != "GameObject": + continue + go = obj.read() + eb = 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": + eb = r; break + if eb is None: + continue + sc = eb.read().m_Script.read() + full = (sc.m_Namespace + "." if sc.m_Namespace else "") + sc.m_ClassName + if full not in nodecache: + nodecache[full] = json.loads(gen.get_nodes_as_json(sc.m_AssemblyName, full)) + try: + tt = eb.read_typetree(nodecache[full]) + except Exception: + continue + sd = tt.get("serializationData", {}) + sb = sd.get("SerializedBytes") + if not sb: + continue + try: + parsed = odin_read.parse(bytes(sb)) + except Exception: + continue + idxs = [] + for root in ("roots", "items"): + find_recipe_indices(parsed.get(root), idxs) + if not idxs: + continue + refs = sd.get("ReferencedUnityObjects", []) + print("\n%s" % go.m_Name) + for i in idxs: + pid = refs[i].get("m_PathID") if i < len(refs) else None + print(" recipeBundle[%d] -> %s" % (i, bundle_names.get(pid, "" % pid))) + + +if __name__ == "__main__": + main()