opus-submitter/polylan_submitter/noita/services/decode.py

292 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.")