""" 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: 3 for sid in ALL_SPELLS}, "ADD_TRIGGER": 10, "ADD_TIMER": 10, "ADD_DEATH_TRIGGER": 10, "NOLLA": 10, "CHAOTIC_TRANSMUTATION": 10, "DUPLICATE": 5, "OMEGA": 10, "BURST_2": 10, "BURST_3": 15, "BURST_4": 20, "BURST_8": 20, "BURST_X": 20, "HEAL_BULLET": 5, "ANTIHEAL": 5, "NUKE": 5, "NUKE_GIGA": 5, "TELEPORT_PROJECTILE": 5, "TELEPORT_PROJECTILE_SHORT": 5, "TOUCH_BLOOD": 100, "TOUCH_GOLD": 100, "TOUCH_PISS": 100, "TOUCH_GRASS": 100, "TOUCH_OIL": 100, "TOUCH_SMOKE": 100, "TOUCH_ALCOHOL": 100, "TOUCH_WATER": 100, "SPELLS_TO_POWER": 10, "ALL_SPELLS": 100, "DIVIDE_2": 10, "DIVIDE_3": 15, "DIVIDE_4": 20, "DIVIDE_10": 50, "ALPHA": 50, "GAMMA": 50, "MU": 50, "OMEGA": 50, "PHI": 50, "SIGMA": 50, "TAU": 50, "ZETA": 50, "DISC_BULLET_BIGGER": 20, "SUMMON_WANDGHOST": 50, "ALL_BLACKHOLES": 20, "ALL_DEATHCROSSES": 20, "ALL_ROCKETS": 20, "ALL_NUKES": 20, "ALL_DISCS": 20, # ── Perks ───────────────────────────────────────────────────────────────── **{pid: 15 for pid in ALL_PERKS}, "PROTECTION_FIRE": 30, "PROTECTION_RADIOACTIVITY": 30, "PROTECTION_EXPLOSION": 30, "PROTECTION_MELEE": 30, "PROTECTION_ELECTRICITY": 30, # ── Spell combos ───────────────────────────────────────────────────────── "PING_PONG_DRILL": 40, "HEAVY_SHOT_DISC": 40, "TWO_ARC_MODIFIERS": 50, "TWO_TRAIL_MODIFIERS": 50, # ── Perk combos ────────────────────────────────────────────────────────── "CRIMSON_ALCHEMIST": 50, "GREEDY_GOBLIN_KING": 50, "STORM_TOUCHED_ASCENDANT": 50, "ARCHMAGE_OF_CONTROL": 50, "HAUNTED_MAGE": 50, "GLASS_CANNON_MESSIAH": 50, "INFINITE_ENGINE": 50, "PERFECT_ACCURACY_LOOP": 50, "HOMING_DEATH_SWARM": 50, "UNTOUCHABLE_FIELD": 50, "ELECTRIC_SUSTAIN_LOOP": 50, "IMMORTAL_LEECH_CORE": 50, "PROJECTILE_OVERLOAD": 50, "CLOSE_RANGE_DEATH_MACHINE": 50, "CRITICAL_MASS": 50, "HOLY_MOUNTAIN_ABUSER": 50, "DEFLECTOR_MATRIX": 50, "STORMBORNE_LEVITATOR": 50, # ── Objectives ─────────────────────────────────────────────────────────── "HP_200": 50, "HP_500": 50, "HP_2000": 100, "HP_5000": 500, "GOLD_1000": 5, "GOLD_10000": 50, "GOLD_100000": 100, "GOLD_1000000": 200, **{f"ORB_{i}": 25 for i in range(34)}, **{f"BOSS_KILL_{i}": 75 * i for i in range(34)}, "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.")