basic noita parsing

This commit is contained in:
2026-05-10 01:45:29 +02:00
parent 119fdc2a51
commit 69b6b46ee2
14 changed files with 1117 additions and 38 deletions
+36 -1
View File
@@ -1 +1,36 @@
# Register your models here.
from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
@admin.register(LogfileSubmission)
class LogfileSubmissionAdmin(admin.ModelAdmin):
list_display = ("id", "user", "content_type", "file_size", "created_at", "processed")
list_filter = ("content_type", "processed", "created_at")
search_fields = ("id", "user__username")
readonly_fields = ("id", "created_at", "updated_at")
fieldsets = (
("Identification", {"fields": ("id",)}),
("File Information", {"fields": ("file", "content_type", "file_size")}),
("User", {"fields": ("user",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}),
("Processing", {"fields": ("processed",)}),
)
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",)
@admin.register(ObjectivPoint)
class ObjectivPointAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "display_string", "max_count", "point")
list_filter = ("objectiv_id",)
search_fields = ("objectiv_id", "display_string")
fieldsets = (
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}),
)
+8 -1
View File
@@ -3,13 +3,20 @@ from django.core.files.base import ContentFile
from ninja import Router, File
from ninja.files import UploadedFile
from .models import LogfileSubmission
from noita.schemas import ObjectivOut
from .models import LogfileSubmission, Objectiv
from .schemas import NoitaSubmissionOut
router = Router()
@router.get("objectives", response=list[ObjectivOut])
def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user)
@router.post("submit", response=NoitaSubmissionOut)
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
"""
@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2026-05-09 23:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0002_rename_submission_logfilesubmission"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Objectiv",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_at", models.DateTimeField()),
("objectiv_id", models.CharField(max_length=64)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-09 23:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0003_objectiv"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="count",
field=models.IntegerField(default=1),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-05-09 23:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("noita", "0004_objectiv_count"),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="updated_at",
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2026-05-09 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0005_remove_objectiv_updated_at"),
]
operations = [
migrations.CreateModel(
name="ObjectivPoint",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("objectiv_id", models.CharField(max_length=64, unique=True)),
("display_string", models.CharField(max_length=255)),
("max_count", models.IntegerField(default=1)),
("point", models.IntegerField(default=0)),
],
),
]
+15
View File
@@ -42,3 +42,18 @@ class LogfileSubmission(models.Model):
updated_at = models.DateTimeField(auto_now=True)
processed = models.BooleanField(default=False)
class Objectiv(models.Model):
objectiv_id = models.CharField(max_length=64)
user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1)
class ObjectivPoint(models.Model):
objectiv_id = models.CharField(max_length=64, unique=True)
display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0)
+10 -5
View File
@@ -1,9 +1,17 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from ninja import Schema, ModelSchema
from noita.models import Objectiv
class NoitaSubmissionOut(BaseModel):
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
class NoitaSubmissionOut(Schema):
id: str
user_id: Optional[int]
username: Optional[str]
@@ -11,6 +19,3 @@ class NoitaSubmissionOut(BaseModel):
content_type: str
created_at: datetime
processed: bool
class Config:
from_attributes = True
+291
View File
@@ -0,0 +1,291 @@
"""
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.")
@@ -0,0 +1,40 @@
from noita.models import LogfileSubmission, Objectiv
from noita.services.decode import parse_log, resolve
from collections import Counter
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
ids = []
for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"])
if idx:
ids.append(idx)
return Counter(ids)
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
"""Parse a logfile and store output."""
if not logfile.user:
return
counter = parse_objectives_from_logfile(logfile)
for idx, count in counter.items():
if idx in {"-", "DEBUG"}:
continue
obj, created = Objectiv.objects.get_or_create(
objectiv_id=idx,
user=logfile.user,
)
obj.count += count
obj.save(update_fields=["count"])
+535
View File
@@ -0,0 +1,535 @@
ALL_SPELLS = [
"FUNKY_SPELL",
"ACIDSHOT",
"BLACK_HOLE",
"BLACK_HOLE_DEATH_TRIGGER",
"BOMB",
"BOMB_CART",
"BUBBLESHOT",
"BUBBLESHOT_TRIGGER",
"AIR_BULLET",
"CHAIN_BOLT",
"CHAINSAW",
"CURSED_ORB",
"ANTIHEAL",
"DEATH_CROSS",
"DEATH_CROSS_BIG",
"LASER_EMITTER_FOUR",
"POWERDIGGER",
"DIGGER",
"PIPE_BOMB",
"PIPE_BOMB_DEATH_TRIGGER",
"GRENADE_LARGE",
"DYNAMITE",
"CRUMBLING_EARTH",
"TENTACLE_PORTAL",
"SLOW_BULLET",
"SLOW_BULLET_TRIGGER",
"SLOW_BULLET_TIMER",
"EXPANDING_ORB",
"FIREBALL",
"GRENADE",
"GRENADE_TRIGGER",
"GRENADE_TIER_2",
"GRENADE_TIER_3",
"GRENADE_ANTI",
"FIREBOMB",
"FIREWORK",
"FLAMETHROWER",
"GLITTER_BOMB",
"LANCE",
"GLUE_SHOT",
"HEAL_BULLET",
"LANCE_HOLY",
"BOMB_HOLY",
"BOMB_HOLY_GIGA",
"HOOK",
"ICEBALL",
"LASER",
"LIGHTNING",
"THUNDERBALL",
"BALL_LIGHTNING",
"LUMINOUS_DRILL",
"LASER_LUMINOUS_DRILL",
"BULLET",
"BULLET_TRIGGER",
"BULLET_TIMER",
"HEAVY_BULLET",
"HEAVY_BULLET_TRIGGER",
"HEAVY_BULLET_TIMER",
"MAGIC_SHIELD",
"BIG_MAGIC_SHIELD",
"ROCKET",
"ROCKET_TIER_2",
"ROCKET_TIER_3",
"METEOR",
"MIST_BLOOD",
"MIST_ALCOHOL",
"MIST_SLIME",
"MIST_RADIOACTIVE",
"BUCKSHOT",
"MEGALASER",
"EXPLODING_DUCKS",
"FREEZING_GAZE",
"INFESTATION",
"NUKE",
"NUKE_GIGA",
"DARKFLAME",
"GLOWING_BOLT",
"LASER_EMITTER",
"LASER_EMITTER_CUTTER",
"POLLEN",
"SPORE_POD",
"PROPANE_TANK",
"RANDOM_PROJECTILE",
"SUMMON_ROCK",
"DISC_BULLET",
"DISC_BULLET_BIG",
"DISC_BULLET_BIGGER",
"SLIMEBALL",
"LIGHT_BULLET",
"LIGHT_BULLET_TRIGGER",
"LIGHT_BULLET_TRIGGER_2",
"LIGHT_BULLET_TIMER",
"RUBBER_BALL",
"ARROW",
"BOUNCY_ORB",
"BOUNCY_ORB_TIMER",
"SPIRAL_SHOT",
"SPITTER",
"SPITTER_TIMER",
"SPITTER_TIER_2",
"SPITTER_TIER_2_TIMER",
"SPITTER_TIER_3",
"SPITTER_TIER_3_TIMER",
"EXPLODING_DEER",
"SUMMON_EGG",
"TNTBOX",
"TNTBOX_BIG",
"FISH",
"SUMMON_HOLLOW_EGG",
"MISSILE",
"PEBBLE",
"TENTACLE",
"TENTACLE_TIMER",
"TELEPORT_PROJECTILE_CLOSER",
"TELEPORT_PROJECTILE_STATIC",
"SWAPPER_PROJECTILE",
"TELEPORT_PROJECTILE",
"TELEPORT_PROJECTILE_SHORT",
"MINE",
"MINE_DEATH_TRIGGER",
"WHITE_HOLE",
"WORM_SHOT",
"WALL_HORIZONTAL",
"WALL_VERTICAL",
"WALL_SQUARE",
"REGENERATION_FIELD",
"FREEZE_FIELD",
"LEVITATION_FIELD",
"TELEPORTATION_FIELD",
"BERSERK_FIELD",
"SHIELD_FIELD",
"ELECTROCUTION_FIELD",
"POLYMORPH_FIELD",
"CHAOS_POLYMORPH_FIELD",
"CLOUD_WATER",
"CLOUD_OIL",
"CLOUD_BLOOD",
"CLOUD_ACID",
"CLOUD_THUNDER",
"DESTRUCTION",
"BOMB_DETONATOR",
"PURPLE_EXPLOSION_FIELD",
"WORM_RAIN",
"METEOR_RAIN",
"SWARM_FLY",
"SWARM_FIREBUG",
"SWARM_WASP",
"DELAYED_SPELL",
"MASS_POLYMORPH",
"PROJECTILE_THUNDER_FIELD",
"PROJECTILE_GRAVITY_FIELD",
"PROJECTILE_TRANSMUTATION_FIELD",
"RANDOM_STATIC_PROJECTILE",
"WHITE_HOLE_BIG",
"BLACK_HOLE_BIG",
"BLACK_HOLE_GIGA",
"THUNDER_BLAST",
"FIRE_BLAST",
"EXPLOSION_LIGHT",
"POISON_BLAST",
"EXPLOSION",
"ALCOHOL_BLAST",
"WHITE_HOLE_GIGA",
"FRIEND_FLY",
"VACUUM_POWDER",
"VACUUM_LIQUID",
"VACUUM_ENTITIES",
"CLUSTERMOD",
"MANA_REDUCE",
"ARC_POISON",
"ARC_FIRE",
"ARC_GUNPOWDER",
"ARC_ELECTRIC",
"ROCKET_DOWNWARDS",
"ROCKET_OCTAGON",
"BOUNCE",
"BOUNCE_SPARK",
"BOUNCE_LASER",
"REMOVE_BOUNCE",
"BOUNCE_SMALL_EXPLOSION",
"BOUNCE_HOLE",
"BOUNCE_EXPLOSION",
"BOUNCE_LARPA",
"BOUNCE_LIGHTNING",
"BOUNCE_LASER_EMITTER",
"EXPLOSION_TINY",
"HITFX_CRITICAL_BLOOD",
"HITFX_CRITICAL_OIL",
"HITFX_CRITICAL_WATER",
"HITFX_BURNING_CRITICAL_HIT",
"CRITICAL_HIT",
"AREA_DAMAGE",
"BLOODLUST",
"DAMAGE",
"DAMAGE_RANDOM",
"DAMAGE_FOREVER",
"ZERO_DAMAGE",
"HEAVY_SHOT",
"LIGHT_SHOT",
"CRUMBLING_EARTH_PROJECTILE",
"FREEZE",
"ELECTRIC_CHARGE",
"HITFX_EXPLOSION_ALCOHOL",
"HITFX_EXPLOSION_ALCOHOL_GIGA",
"HITFX_EXPLOSION_SLIME",
"HITFX_EXPLOSION_SLIME_GIGA",
"HITFX_TOXIC_CHARM",
"COLOUR_RED",
"COLOUR_ORANGE",
"COLOUR_YELLOW",
"COLOUR_GREEN",
"COLOUR_BLUE",
"COLOUR_PURPLE",
"COLOUR_RAINBOW",
"COLOUR_INVIS",
"HEAVY_SPREAD",
"KNOCKBACK",
"LARPA_CHAOS_2",
"LARPA_CHAOS",
"LARPA_DOWNWARDS",
"LARPA_DEATH",
"LARPA_UPWARDS",
"LIFETIME_DOWN",
"LIFETIME",
"CHAIN_SHOT",
"NOLLA",
"LIGHT",
"NECROMANCY",
"ORBIT_DISCS",
"ORBIT_NUKES",
"ORBIT_FIREBALLS",
"ORBIT_LARPA",
"ORBIT_LASERS",
"LINE_ARC",
"HORIZONTAL_ARC",
"GRAVITY",
"GRAVITY_ANTI",
"FLY_UPWARDS",
"FLY_DOWNWARDS",
"ORBIT_SHOT",
"TRUE_ORBIT",
"SPIRALING_SHOT",
"PINGPONG_PATH",
"PHASING_ARC",
"CHAOTIC_ARC",
"SINEWAVE",
"HOMING_WAND",
"HOMING_CURSOR",
"HOMING_SHORT",
"AUTOAIM",
"HOMING",
"HOMING_ROTATE",
"HOMING_SHOOTER",
"ANTI_HOMING",
"HOMING_ACCELERATING",
"HOMING_AREA",
"FIREBALL_RAY_ENEMY",
"GRAVITY_FIELD_ENEMY",
"TENTACLE_RAY_ENEMY",
"LIGHTNING_RAY_ENEMY",
"HITFX_PETRIFY",
"PIERCING_SHOT",
"LASER_EMITTER_WIDER",
"QUANTUM_SPLIT",
"RANDOM_EXPLOSION",
"RANDOM_MODIFIER",
"RECOIL",
"RECOIL_DAMPER",
"RECHARGE",
"SPREAD_REDUCE",
"EXPLOSION_REMOVE",
"SLOW_BUT_STEADY",
"ENERGY_SHIELD_SHOT",
"SPEED",
"DECELERATING_SHOT",
"ACCELERATING_SHOT",
"FIZZLE",
"FLOATING_ARC",
"AVOIDING_ARC",
"CLIPPING_SHOT",
"UNSTABLE_GUNPOWDER",
"MATTER_EATER",
"EXPLOSIVE_PROJECTILE",
"FIREBALL_RAY_LINE",
"FIREBALL_RAY",
"LIGHTNING_RAY",
"TENTACLE_RAY",
"LASER_EMITTER_RAY",
"SPELLS_TO_POWER",
"ESSENCE_TO_POWER",
"ACID_TRAIL",
"FIRE_TRAIL",
"GUNPOWDER_TRAIL",
"OIL_TRAIL",
"POISON_TRAIL",
"RAINBOW_TRAIL",
"WATER_TRAIL",
"BURN_TRAIL",
"WATER_TO_POISON",
"BLOOD_TO_ACID",
"LAVA_TO_BLOOD",
"LIQUID_TO_EXPLOSION",
"TOXIC_TO_ACID",
"STATIC_TO_SAND",
"TRANSMUTATION",
"CURSE_WITHER_ELECTRICITY",
"CURSE_WITHER_EXPLOSION",
"CURSE_WITHER_MELEE",
"CURSE_WITHER_PROJECTILE",
"CURSE",
"BURST_2",
"BURST_3",
"BURST_4",
"BURST_8",
"BURST_X",
"SCATTER_2",
"SCATTER_3",
"SCATTER_4",
"I_SHAPE",
"T_SHAPE",
"PENTAGRAM_SHAPE",
"CIRCLE_SHAPE",
"Y_SHAPE",
"W_SHAPE",
"TOUCH_PISS",
"TOUCH_GRASS",
"SOILBALL",
"CIRCLE_FIRE",
"CIRCLE_ACID",
"CIRCLE_OIL",
"CIRCLE_WATER",
"MATERIAL_BLOOD",
"MATERIAL_CEMENT",
"MATERIAL_OIL",
"MATERIAL_ACID",
"MATERIAL_WATER",
"SEA_LAVA",
"SEA_ALCOHOL",
"SEA_OIL",
"SEA_WATER",
"SEA_ACID",
"SEA_ACID_GAS",
"SEA_SWAMP",
"SEA_MIMIC",
"TOUCH_BLOOD",
"TOUCH_GOLD",
"TOUCH_OIL",
"TOUCH_SMOKE",
"TOUCH_ALCOHOL",
"TOUCH_WATER",
"X_RAY",
"BLOOD_MAGIC",
"CASTER_CAST",
"I_SHOT",
"Y_SHOT",
"T_SHOT",
"W_SHOT",
"QUAD_SHOT",
"PENTA_SHOT",
"HEXA_SHOT",
"SUPER_TELEPORT_CAST",
"TELEPORT_CAST",
"LONG_DISTANCE_CAST",
"ALL_ACID",
"ALL_BLACKHOLES",
"ALL_DEATHCROSSES",
"ALL_ROCKETS",
"ALL_NUKES",
"ALL_DISCS",
"SUMMON_WANDGHOST",
"TEMPORARY_PLATFORM",
"TEMPORARY_WALL",
"MONEY_MAGIC",
"BLOOD_TO_POWER",
"RESET",
"ENERGY_SHIELD",
"ENERGY_SHIELD_SECTOR",
"TINY_GHOST",
"TORCH",
"TORCH_ELECTRIC",
"ADD_TRIGGER",
"ADD_TIMER",
"ADD_DEATH_TRIGGER",
"CESSATION",
"DIVIDE_2",
"DIVIDE_3",
"DIVIDE_4",
"DIVIDE_10",
"OMEGA",
"ZETA",
"TAU",
"SIGMA",
"PHI",
"MU",
"ALPHA",
"GAMMA",
"KANTELE_A",
"KANTELE_D",
"KANTELE_DIS",
"KANTELE_E",
"KANTELE_G",
"OCARINA_A",
"OCARINA_B",
"OCARINA_C",
"OCARINA_D",
"OCARINA_E",
"OCARINA_F",
"OCARINA_GSHARP",
"OCARINA_A2",
"RANDOM_SPELL",
"DRAW_RANDOM",
"DRAW_RANDOM_X3",
"DRAW_3_RANDOM",
"IF_PROJECTILE",
"IF_HP",
"IF_ENEMY",
"IF_HALF",
"IF_ELSE",
"IF_END",
"DUPLICATE",
"SUMMON_PORTAL",
"ALL_SPELLS",
]
ALL_PERKS = [
"CRITICAL_HIT",
"BREATH_UNDERWATER",
"EXTRA_MONEY",
"EXTRA_MONEY_TRICK_KILL",
"GOLD_IS_FOREVER",
"TRICK_BLOOD_MONEY",
"EXPLODING_GOLD",
"HOVER_BOOST",
"FASTER_LEVITATION",
"MOVEMENT_FASTER",
"LOW_GRAVITY",
"HIGH_GRAVITY",
"SPEED_DIVER",
"STRONG_KICK",
"TELEKINESIS",
"REPELLING_CAPE",
"EXPLODING_CORPSES",
"SAVING_GRACE",
"INVISIBILITY",
"GLOBAL_GORE",
"REMOVE_FOG_OF_WAR",
"LEVITATION_TRAIL",
"VAMPIRISM",
"EXTRA_HP",
"HEARTS_MORE_EXTRA_HP",
"GLASS_CANNON",
"LOW_HP_DAMAGE_BOOST",
"RESPAWN",
"WORM_ATTRACTOR",
"WORM_DETRACTOR",
"RADAR_ENEMY",
"FOOD_CLOCK",
"WAND_RADAR",
"ITEM_RADAR",
"MOON_RADAR",
"PROTECTION_FIRE",
"PROTECTION_RADIOACTIVITY",
"PROTECTION_EXPLOSION",
"PROTECTION_MELEE",
"PROTECTION_ELECTRICITY",
"TELEPORTITIS",
"TELEPORTITIS_DODGE",
"STAINLESS_ARMOUR",
"EDIT_WANDS_EVERYWHERE",
"NO_WAND_EDITING",
"WAND_EXPERIMENTER",
"ADVENTURER",
"ABILITY_ACTIONS_MATERIALIZED",
"PROJECTILE_HOMING",
"PROJECTILE_HOMING_SHOOTER",
"UNLIMITED_SPELLS",
"FREEZE_FIELD",
"FIRE_GAS",
"DISSOLVE_POWDERS",
"BLEED_SLIME",
"BLEED_OIL",
"BLEED_GAS",
"SHIELD",
"REVENGE_EXPLOSION",
"REVENGE_TENTACLE",
"REVENGE_RATS",
"REVENGE_BULLET",
"ATTACK_FOOT",
"LEGGY_FEET",
"PLAGUE_RATS",
"VOMIT_RATS",
"CORDYCEPS",
"MOLD",
"WORM_SMALLER_HOLES",
"PROJECTILE_REPULSION",
"RISKY_CRITICAL",
"FUNGAL_DISEASE",
"PROJECTILE_SLOW_FIELD",
"PROJECTILE_REPULSION_SECTOR",
"PROJECTILE_EATER_SECTOR",
"ORBIT",
"ANGRY_GHOST",
"HUNGRY_GHOST",
"DEATH_GHOST",
"HOMUNCULUS",
"ELECTRICITY",
"ATTRACT_ITEMS",
"EXTRA_KNOCKBACK",
"LOWER_SPREAD",
"LOW_RECOIL",
"BOUNCE",
"FAST_PROJECTILES",
"ALWAYS_CAST",
"EXTRA_MANA",
"NO_MORE_SHUFFLE",
"NO_MORE_KNOCKBACK",
"DUPLICATE_PROJECTILE",
"FASTER_WANDS",
"EXTRA_SLOTS",
"CONTACT_DAMAGE",
"EXTRA_PERK",
"PERKS_LOTTERY",
"GAMBLE",
"EXTRA_SHOP_ITEM",
"GENOME_MORE_HATRED",
"GENOME_MORE_LOVE",
"PEACE_WITH_GODS",
"MANA_FROM_KILLS",
"ANGRY_LEVITATION",
"LASER_AIM",
"PERSONAL_LASER",
"MEGA_BEAM_STONE",
"IRON_STOMACH",
]