""" Decode a polylan_mod_log.txt. Hash format: sha1(seed|timestamp|id) — timestamp is bound into the hash so it cannot be altered without breaking the signature. Seed-less entries (INIT, DEBUG, mod checks) use sha1(id) with no seed or timestamp — those are resolved via a static lookup. Usage: python decode_log.py [path/to/polylan_mod_log.txt] Default path: ~/.local/share/Steam/steamapps/common/Noita/polylan_mod_log.txt """ import hashlib import re from functools import cache from noita.services.spells import ALL_SPELLS, ALL_PERKS SEED_POOL = [ # General good seeds 3154823, 3718311, 10064758, 123156801, 1024089369, 1026967166, # Pacifist seeds 177795258, 520542929, 10600249, 25300740, 21875589, 24085389, 59775105, 44190726, 1039649471, 1072607354, # Perk combo seeds 839747651, 839844768, 840909713, 839959129, 840016192, 840039886, 840398606, 840439045, 840457463, 840492754, 840507802, 840513742, 840542079, 840574169, 840610974, 840626894, 840872436, 840894605, 841221188, ] DEATH_PENALTY = 1 # All scoreable events: base 1 pt for every spell and perk, overrides below. POINTS = { # ── Spells ─────────────────────────────────────────────────────────────── **{sid: 1 for sid in ALL_SPELLS}, "ADD_TRIGGER": 10, "NOLLA": 10, "CHAOTIC_TRANSMUTATION": 10, "DUPLICATE": 5, "OMEGA": 10, "HEALING_BOLT": 5, # ── Perks ───────────────────────────────────────────────────────────────── **{pid: 1 for pid in ALL_PERKS}, "EDIT_WANDS_EVERYWHERE": 10, # ── Spell combos ───────────────────────────────────────────────────────── "PING_PONG_DRILL": 10, "HEAVY_SHOT_DISC": 10, "TOUCH_OF_ANY": 5, "TWO_ARC_MODIFIERS": 10, "TWO_TRAIL_MODIFIERS": 10, # ── Perk combos ────────────────────────────────────────────────────────── "CRIMSON_ALCHEMIST": 15, "GREEDY_GOBLIN_KING": 15, "STORM_TOUCHED_ASCENDANT": 15, "ARCHMAGE_OF_CONTROL": 15, "HAUNTED_MAGE": 15, "GLASS_CANNON_MESSIAH": 15, "INFINITE_ENGINE": 15, "PERFECT_ACCURACY_LOOP": 15, "HOMING_DEATH_SWARM": 15, "UNTOUCHABLE_FIELD": 15, "ELECTRIC_SUSTAIN_LOOP": 15, "IMMORTAL_LEECH_CORE": 15, "PROJECTILE_OVERLOAD": 15, "CLOSE_RANGE_DEATH_MACHINE": 15, "CRITICAL_MASS": 15, "HOLY_MOUNTAIN_ABUSER": 15, "DEFLECTOR_MATRIX": 15, "STORMBORNE_LEVITATOR": 15, # ── Objectives ─────────────────────────────────────────────────────────── "HP_200": 5, "HP_500": 5, "HP_2000": 5, "HP_5000": 5, "GOLD_1000": 5, "GOLD_10000": 5, "GOLD_100000": 5, "GOLD_1000000": 5, **{f"ORB_{i}": 5 for i in range(34)}, "FIND_AMBROSIA": 10, "WAND_MANA_500": 10, "WAND_MANA_1000": 10, "WAND_MANA_1500": 10, "WAND_CAPACITY_10": 10, "WAND_CAPACITY_20": 10, "BOSS_KILL": 100, "CRIMSON_ALCHEMIST-BOSS_KILL": 100, "GREEDY_GOBLIN_KING-BOSS_KILL": 100, "STORM_TOUCHED_ASCENDANT-BOSS_KILL": 100, "ARCHMAGE_OF_CONTROL-BOSS_KILL": 100, "HAUNTED_MAGE-BOSS_KILL": 100, "GLASS_CANNON_MESSIAH-BOSS_KILL": 100, "INFINITE_ENGINE-BOSS_KILL": 100, "PERFECT_ACCURACY_LOOP-BOSS_KILL": 100, "HOMING_DEATH_SWARM-BOSS_KILL": 100, "UNTOUCHABLE_FIELD-BOSS_KILL": 100, "ELECTRIC_SUSTAIN_LOOP-BOSS_KILL": 100, "IMMORTAL_LEECH_CORE-BOSS_KILL": 100, "PROJECTILE_OVERLOAD-BOSS_KILL": 100, "CLOSE_RANGE_DEATH_MACHINE-BOSS_KILL": 100, "CRITICAL_MASS-BOSS_KILL": 100, "HOLY_MOUNTAIN_ABUSER-BOSS_KILL": 100, "DEFLECTOR_MATRIX-BOSS_KILL": 100, "STORMBORNE_LEVITATOR-BOSS_KILL": 100, } # Perk-combo IDs — used to award a per-combo boss-kill bonus. PERK_COMBO_IDS = { "CRIMSON_ALCHEMIST", "GREEDY_GOBLIN_KING", "STORM_TOUCHED_ASCENDANT", "ARCHMAGE_OF_CONTROL", "HAUNTED_MAGE", "GLASS_CANNON_MESSIAH", "INFINITE_ENGINE", "PERFECT_ACCURACY_LOOP", "HOMING_DEATH_SWARM", "UNTOUCHABLE_FIELD", "ELECTRIC_SUSTAIN_LOOP", "IMMORTAL_LEECH_CORE", "PROJECTILE_OVERLOAD", "CLOSE_RANGE_DEATH_MACHINE", "CRITICAL_MASS", "HOLY_MOUNTAIN_ABUSER", "DEFLECTOR_MATRIX", "STORMBORNE_LEVITATOR", } BOSS_KILL_COMBO_BONUS = 100 # default bonus per active perk combo on boss kill # IDs tried with the seed+timestamp scheme. _ALL_IDS = list(POINTS.keys()) + ["DEATH"] # Seed-less hashes (sha1(id) only, no seed, no timestamp). _STATIC_LOOKUP = { hashlib.sha1(k.encode()).hexdigest(): k for k in ["-", "DEBUG", "polylan-mod", "cheatgui", "alchemy_recipes_display"] } def _sha1(text: str) -> str: return hashlib.sha1(text.encode("utf-8")).hexdigest() @cache def resolve( hash_val: str, ts: str, preferred_seed: int | None = None ) -> tuple[str | None, int | None]: """Return (name, seed) or (None, None). preferred_seed is tried first.""" if hash_val in _STATIC_LOOKUP: return _STATIC_LOOKUP[hash_val], None seeds = ( [preferred_seed] + [s for s in SEED_POOL if s != preferred_seed] if preferred_seed is not None else SEED_POOL ) for seed in seeds: prefix = f"{seed}|{ts}|" for name in _ALL_IDS: if _sha1(prefix + name) == hash_val: return name, seed return None, None def parse_log(file) -> list: entries = [] for il, line in enumerate(file.split("\n")): m = re.match(r"\[(.+?)\] ([0-9a-f]{40})", line.rstrip()) if m: entries.append({"ts": m.group(1), "hash": m.group(2)}) continue print(f"Unable to parse line number {il:>4}: {line.strip()}??") return entries def decode(file) -> None: entries = parse_log(file) if not entries: print("No entries found in log.") return seen = set() total = 0 deaths = 0 have_init = False have_debug = False known_seed = None # cached once first seeded entry is resolved current_run_perk_combos = set() # perk combos seen since the last "-" entry for entry in entries: name, seed = resolve(entry["hash"], entry["ts"], known_seed) if name is None: print(f" ???? (unresolved) {entry['hash']} ts: {entry['ts']}") continue if seed is not None and known_seed is None: known_seed = seed if name == "-": have_init = True current_run_perk_combos = set() continue if name == "DEBUG": have_debug = True continue if name == "DEATH": deaths += 1 continue # skip mod-presence entries if name not in POINTS: continue if name in seen: continue if name not in {"BOSS_KILL"}: seen.add(name) if name in PERK_COMBO_IDS: current_run_perk_combos.add(name) pts = POINTS.get(name, 0) if pts: print(f" +{pts:>4} {name:<40} first seen: {entry['ts']}") total += pts if name == "BOSS_KILL": for combo_id in sorted(current_run_perk_combos): bonus_key = f"{combo_id}-BOSS_KILL" if bonus_key not in seen: seen.add(bonus_key) bonus_pts = POINTS.get(bonus_key, BOSS_KILL_COMBO_BONUS) print(f" +{bonus_pts:>4} {bonus_key:<40} combo bonus") total += bonus_pts death_deduction = deaths * DEATH_PENALTY if deaths: print( f" -{death_deduction:>4} ({deaths} death{'s' if deaths > 1 else ''} × {DEATH_PENALTY} pts)" ) if have_init: print( f"\nTotal: {total - death_deduction} pts ({total} earned − {death_deduction} penalty)" ) if have_debug: print("Note: DEBUG mode was active during at least one session.")