292 lines
8.4 KiB
Python
292 lines
8.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: 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.")
|