335 lines
9.4 KiB
Python
335 lines
9.4 KiB
Python
"""
|
||
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,
|
||
"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.")
|