From bd145b1e5567fdb914f37c2f3eb0260d55f1da16 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Thu, 22 Oct 2026 11:19:14 -0400 Subject: [PATCH] Load Powers.cfg at world boot PowerParser loads Powers.cfg into PowerTemplate._powers. The 22 header fields are named after the client loader's error messages (0x56E410); ACTION= lines keep trains, duration, curve and stack fields; prereqs, curves, flags, messages and CONDITION blocks are kept. Keys compare case-insensitively, as in the client. Four keys the client ignores (APPLYDAMAGESELF=, APPLYEFFECTCASTER=, APPLYEFFECTTARGET= and a POWEREND= typo) are skipped; any other unknown key fails the load. Numbers are read like the client's atoi/atof with a warning when a token isn't clean: PRL-033 init time "2.9.0" -> 2.9 and SGE-001 precision "10.0" -> 10, which matches the DB. Ten EQPREREQ lines end without a required flag, which the client reads as 0. Needs the fixed Powers.cfg (SRDX-DB header had "SPELL 15 Unknown" pasted twice; fixed in MBDATA, findings section 6). Co-Authored-By: Claude Opus 5.5 --- .../parsers/objects/wpak/PowerTemplate.java | 132 +++++++ src/engine/parsers/wpak/EffectParser.java | 2 +- src/engine/parsers/wpak/PowerParser.java | 330 ++++++++++++++++++ src/engine/parsers/wpak/WpakLine.java | 26 +- src/engine/server/world/WorldServer.java | 10 + 5 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 src/engine/parsers/objects/wpak/PowerTemplate.java create mode 100644 src/engine/parsers/wpak/PowerParser.java diff --git a/src/engine/parsers/objects/wpak/PowerTemplate.java b/src/engine/parsers/objects/wpak/PowerTemplate.java new file mode 100644 index 00000000..b373d53c --- /dev/null +++ b/src/engine/parsers/objects/wpak/PowerTemplate.java @@ -0,0 +1,132 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.parsers.objects.wpak; + +import engine.mbEnums; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; + +// One POWERBEGIN block of Powers.cfg (client loader 0x56E410). +// Header field names follow the client's " Error with line" messages. +// KEY= lines map to the lower-case fields named after them. +// +// Read only once loaded. + +public class PowerTemplate { + + public static HashMap _powers = new HashMap<>(); + + // Header: 22 tokens + + public String power_id; + public String power_name; + public mbEnums.PowerType power_type; + public int icon_number; + public String focus; + public mbEnums.PowerTargetType target_type; + public float range; + public mbEnums.AreaType area_type; + public float area_range; + public mbEnums.ExcludeType exclude_type; + public mbEnums.CostType cost_type; + public float cost_amt; + public int difficulty; + public int precision; + public float init_time; + public float release_time; + public float recycle_time; + public boolean hit_roll_yn; + public mbEnums.CastingModeType casting_mode; + public int init_anim; + public int release_anim; + public mbEnums.TargetSelectType target_sel; + + // Keys + + public final ArrayList actions = new ArrayList<>(); + public int maxlevel; + public float hatevalue; + public String hatevalue_curve; + public String grantoverridevar; + public mbEnums.PowerCategoryType category; + public final ArrayList eqprereqs = new ArrayList<>(); + public final ArrayList monstertypeprereqs = new ArrayList<>(); + public final ArrayList monstertyperestricts = new ArrayList<>(); + public final ArrayList targeteffectprereqs_anded = new ArrayList<>(); + public final ArrayList targeteffectprereqs_ored = new ArrayList<>(); + public final ArrayList effectprereqs = new ArrayList<>(); + + // CURVE= : the curve applied to that header value + public final LinkedHashMap curves = new LinkedHashMap<>(); + + public final ArrayList descriptions = new ArrayList<>(); + public int loopanimid; + public boolean bladetrails; + public boolean cancastwhilemoving; + public boolean cancastwhileflying; + public boolean shouldcheckpath; + public boolean isadminpower; + public boolean cantargetadmin; + public boolean isprojectile; + public boolean sticky; + public int maxnummobtargets; + public int maxnumplayertargets; + public float pulseinfo_duration; + public int pulseinfo_iterations; + public final ArrayList sounds = new ArrayList<>(); + public int casterspulseparticle; + public final ArrayList conditions = new ArrayList<>(); + + public String initstring; + public String successself; + public String successother; + public String fizzleself; + public String fizzleother; + public String applyeffectself; + public String applyeffectother; + public String wearoffeffectself; + public String wearoffeffectother; + public String applydamagecaster; + public String applydamagetarget; + public String applydamageother; + + // ACTION= + // , then up to four optional pairs kept only in args + + public static class ActionEntry { + + public String action_id; + public int min_trains; + public int max_trains; + public float duration; + public String duration_curve; + public String stack_type; + public int stack_order; + public mbEnums.CategoryToPowerType stack_priority; + public final ArrayList args = new ArrayList<>(); + } + + // EQPREREQ= repeats + + public static class EquipPrereq { + + public mbEnums.EquipSlotType slot; + public String skill; + public boolean required; + } + + public static class EffectPrereq { + + public String effect_id; + public int value; + public String message; + } +} diff --git a/src/engine/parsers/wpak/EffectParser.java b/src/engine/parsers/wpak/EffectParser.java index c391af8a..b0bc9669 100644 --- a/src/engine/parsers/wpak/EffectParser.java +++ b/src/engine/parsers/wpak/EffectParser.java @@ -208,7 +208,7 @@ public class EffectParser { mod.curve = WpakParserUtil.curveToken(line, 3); } - private static EffectTemplate.ConditionEntry parseCondition(WpakLine line) { + static EffectTemplate.ConditionEntry parseCondition(WpakLine line) { EffectTemplate.ConditionEntry condition = new EffectTemplate.ConditionEntry(); diff --git a/src/engine/parsers/wpak/PowerParser.java b/src/engine/parsers/wpak/PowerParser.java new file mode 100644 index 00000000..a2973ca5 --- /dev/null +++ b/src/engine/parsers/wpak/PowerParser.java @@ -0,0 +1,330 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.parsers.wpak; + +import engine.mbEnums; +import engine.parsers.objects.wpak.PowerTemplate; +import org.pmw.tinylog.Logger; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; + +// Loads Powers.cfg (client loader 0x56E410). CompoundCurves.cfg must be loaded first. + +public class PowerParser { + + private static final String fileName = "Powers.cfg"; + private static final int headerSize = 22; + + // Keys the client's loader doesn't know and skips; "POWEREND=" is a typo for CURVE= on one power + private static final HashSet clientIgnoredKeys = new HashSet<>(Arrays.asList( + "APPLYDAMAGESELF=", "APPLYEFFECTCASTER=", "APPLYEFFECTTARGET=", "POWEREND=")); + + public static HashMap parsePowersFile() { + + HashMap powers = new HashMap<>(); + WpakBlock root; + PowerTemplate power; + int blockCount = 0; + int failedCount = 0; + + try { + root = WpakFileReader.readBlocks(fileName); + } catch (Exception e) { + Logger.error(fileName + ": " + e.getMessage()); + return null; + } + + for (WpakLine line : root.lines()) { + Logger.error(line.location() + ": line outside POWERBEGIN/END"); + failedCount++; + } + + for (WpakBlock block : root.blocks()) { + try { + if (!block.name.equals("POWER")) + throw block.beginLine.error("unexpected " + block.name + "BEGIN"); + + blockCount++; + power = parsePowerBlock(block); + + if (powers.containsKey(power.power_id)) + Logger.warn(block.location() + ": duplicate power " + power.power_id + "; the later entry wins"); + + powers.put(power.power_id, power); + } catch (WpakFormatException e) { + Logger.error(e.getMessage()); + failedCount++; + } catch (Exception e) { + Logger.error(block.location() + ": " + e); + failedCount++; + } + } + + Logger.info("Loaded " + powers.size() + " of " + blockCount + " powers"); + + if (failedCount > 0) + return null; + + return powers; + } + + private static PowerTemplate parsePowerBlock(WpakBlock block) { + + PowerTemplate power = new PowerTemplate(); + ArrayList lines = block.lines(); + + if (lines.isEmpty()) + throw block.beginLine.error("power has no header line"); + + parseHeader(power, lines.get(0)); + + for (int i = 1; i < lines.size(); i++) + parseKey(power, lines.get(i)); + + for (WpakBlock child : block.blocks()) { + if (!child.name.equals("CONDITION")) + throw child.beginLine.error("unexpected " + child.name + "BEGIN in a power"); + + for (WpakLine line : child.lines()) + power.conditions.add(EffectParser.parseCondition(line)); + } + + return power; + } + + private static void parseHeader(PowerTemplate power, WpakLine header) { + + if (header.size() != headerSize) + throw header.error("power header has " + header.size() + " tokens, expected " + headerSize); + + power.power_id = header.token(0); + power.power_name = header.token(1); + power.power_type = header.enumToken(mbEnums.PowerType.class, 2); + power.icon_number = header.intToken(3); + power.focus = header.token(4); + power.target_type = header.enumToken(mbEnums.PowerTargetType.class, 5); + power.range = header.floatToken(6); + power.area_type = header.enumToken(mbEnums.AreaType.class, 7); + power.area_range = header.floatToken(8); + power.exclude_type = header.enumToken(mbEnums.ExcludeType.class, 9); + power.cost_type = header.enumToken(mbEnums.CostType.class, 10); + power.cost_amt = header.floatToken(11); + power.difficulty = header.intToken(12); + power.precision = header.intToken(13); + power.init_time = header.floatToken(14); + power.release_time = header.floatToken(15); + power.recycle_time = header.floatToken(16); + power.hit_roll_yn = header.boolToken(17); + power.casting_mode = header.enumToken(mbEnums.CastingModeType.class, 18); + power.init_anim = header.intToken(19); + power.release_anim = header.intToken(20); + power.target_sel = header.enumToken(mbEnums.TargetSelectType.class, 21); + } + + // The client compares keys case-insensitively (cfg has MaxLevel= and HateValue=) + + private static void parseKey(PowerTemplate power, WpakLine line) { + + String key = line.token(0).toUpperCase(); + + switch (key) { + case "ACTION=": + power.actions.add(parseAction(line)); + break; + case "MAXLEVEL=": + power.maxlevel = line.intToken(1); + break; + case "HATEVALUE=": + power.hatevalue = line.floatToken(1); + power.hatevalue_curve = line.has(2) ? WpakParserUtil.curveToken(line, 2) : null; + break; + case "GRANTOVERRIDEVAR=": + power.grantoverridevar = line.token(1); + break; + case "CATEGORY=": + power.category = line.enumToken(mbEnums.PowerCategoryType.class, 1); + break; + case "EQPREREQ=": + parseEquipPrereqs(power, line); + break; + case "MONSTERTYPEPREREQS=": + for (int i = 1; i < line.size(); i++) + power.monstertypeprereqs.add(line.enumToken(mbEnums.MonsterType.class, i)); + break; + case "MONSTERTYPERESTRICTS=": + for (int i = 1; i < line.size(); i++) + power.monstertyperestricts.add(line.enumToken(mbEnums.MonsterType.class, i)); + break; + case "TARGETEFFECTPREREQS_ANDED=": + parseEffectPrereqPairs(power.targeteffectprereqs_anded, line); + break; + case "TARGETEFFECTPREREQS_ORED=": + parseEffectPrereqPairs(power.targeteffectprereqs_ored, line); + break; + case "EFFECTPREREQ=": + power.effectprereqs.add(parseEffectPrereq(line)); + break; + case "CURVE=": + power.curves.put(line.token(1), WpakParserUtil.curveToken(line, 2)); + break; + case "DESCRIPTION=": + power.descriptions.add(line.token(1)); + break; + case "LOOPANIMID=": + power.loopanimid = line.intToken(1); + break; + case "BLADETRAILS=": + power.bladetrails = line.optionalBoolToken(1); + break; + case "CANCASTWHILEMOVING=": + power.cancastwhilemoving = line.optionalBoolToken(1); + break; + case "CANCASTWHILEFLYING=": + power.cancastwhileflying = line.optionalBoolToken(1); + break; + case "SHOULDCHECKPATH=": + power.shouldcheckpath = line.optionalBoolToken(1); + break; + case "ISADMINPOWER=": + power.isadminpower = line.optionalBoolToken(1); + break; + case "CANTARGETADMIN=": + power.cantargetadmin = line.optionalBoolToken(1); + break; + case "ISPROJECTILE=": + power.isprojectile = line.optionalBoolToken(1); + break; + case "STICKY=": + power.sticky = line.optionalBoolToken(1); + break; + case "MAXNUMMOBTARGETS=": + power.maxnummobtargets = line.intToken(1); + break; + case "MAXNUMPLAYERTARGETS=": + power.maxnumplayertargets = line.intToken(1); + break; + case "PULSEINFO=": + power.pulseinfo_duration = line.floatToken(1); + power.pulseinfo_iterations = line.intToken(2); + break; + case "SOUNDS=": + power.sounds.addAll(line.intTokensFrom(1)); + break; + case "CASTERSPULSEPARTICLE=": + power.casterspulseparticle = line.intToken(1); + break; + case "INITSTRING=": + power.initstring = line.token(1); + break; + case "SUCCESSSELF=": + power.successself = line.token(1); + break; + case "SUCCESSOTHER=": + power.successother = line.token(1); + break; + case "FIZZLESELF=": + power.fizzleself = line.token(1); + break; + case "FIZZLEOTHER=": + power.fizzleother = line.token(1); + break; + case "APPLYEFFECTSELF=": + power.applyeffectself = line.token(1); + break; + case "APPLYEFFECTOTHER=": + power.applyeffectother = line.token(1); + break; + case "WEAROFFEFFECTSELF=": + power.wearoffeffectself = line.token(1); + break; + case "WEAROFFEFFECTOTHER=": + power.wearoffeffectother = line.token(1); + break; + case "APPLYDAMAGECASTER=": + power.applydamagecaster = line.token(1); + break; + case "APPLYDAMAGETARGET=": + power.applydamagetarget = line.token(1); + break; + case "APPLYDAMAGEOTHER=": + power.applydamageother = line.token(1); + break; + default: + if (!clientIgnoredKeys.contains(key)) + throw line.error("unknown power key " + line.token(0)); + } + } + + private static PowerTemplate.ActionEntry parseAction(WpakLine line) { + + PowerTemplate.ActionEntry action = new PowerTemplate.ActionEntry(); + + action.action_id = line.token(1); + action.min_trains = line.intToken(2); + action.max_trains = line.intToken(3); + action.duration = line.floatToken(4); + action.duration_curve = WpakParserUtil.curveToken(line, 5); + action.stack_type = line.token(6); + action.stack_order = line.intToken(7); + action.stack_priority = line.enumToken(mbEnums.CategoryToPowerType.class, 8); + action.args.addAll(line.tokens.subList(1, line.size())); + + return action; + } + + // Ten lines end in "LHELD Bow" with no flag; the client reads the missing flag as 0 + + private static void parseEquipPrereqs(PowerTemplate power, WpakLine line) { + + PowerTemplate.EquipPrereq prereq; + + for (int i = 1; i < line.size(); i += 3) { + prereq = new PowerTemplate.EquipPrereq(); + prereq.slot = line.enumToken(mbEnums.EquipSlotType.class, i); + prereq.skill = line.token(i + 1); + prereq.required = line.optionalBoolToken(i + 2); + power.eqprereqs.add(prereq); + } + } + + private static void parseEffectPrereqPairs(ArrayList prereqs, WpakLine line) { + + PowerTemplate.EffectPrereq prereq; + + for (int i = 1; i < line.size(); i += 2) { + prereq = new PowerTemplate.EffectPrereq(); + prereq.effect_id = line.token(i); + prereq.value = line.intToken(i + 1); + prereqs.add(prereq); + } + } + + // The message is the rest of the line; two lines wrap it in doubled quotes (""text""), + // which split it into words with empty tokens at each end + + private static PowerTemplate.EffectPrereq parseEffectPrereq(WpakLine line) { + + PowerTemplate.EffectPrereq prereq = new PowerTemplate.EffectPrereq(); + ArrayList words = new ArrayList<>(); + + prereq.effect_id = line.token(1); + prereq.value = line.intToken(2); + + for (int i = 3; i < line.size(); i++) + if (!line.token(i).isEmpty()) + words.add(line.token(i)); + + prereq.message = String.join(" ", words); + + return prereq; + } +} diff --git a/src/engine/parsers/wpak/WpakLine.java b/src/engine/parsers/wpak/WpakLine.java index f3524f05..dff43fe8 100644 --- a/src/engine/parsers/wpak/WpakLine.java +++ b/src/engine/parsers/wpak/WpakLine.java @@ -9,14 +9,22 @@ package engine.parsers.wpak; import engine.parsers.cache.CacheParserUtil; +import org.pmw.tinylog.Logger; import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; // One non-comment line of a wpak cfg file, split into tokens. // Every accessor that fails names the file and line. public class WpakLine { + // The client reads numbers like C atoi/atof: the leading number counts, the rest is dropped + // (Powers.cfg has "2.9.0" for a float and "10.0" for an int) + private static final Pattern intPrefix = Pattern.compile("[-+]?\\d+"); + private static final Pattern floatPrefix = Pattern.compile("[-+]?(\\d+\\.?\\d*|\\.\\d+)"); + public final String fileName; public final int lineNumber; public final String text; @@ -56,23 +64,37 @@ public class WpakLine { public int intToken(int index) { String value = token(index); + Matcher prefix; try { return Integer.parseInt(value); } catch (NumberFormatException e) { - throw error("token " + index + " '" + value + "' is not an integer"); + prefix = intPrefix.matcher(value); } + + if (!prefix.lookingAt()) + throw error("token " + index + " '" + value + "' is not an integer"); + + Logger.warn(location() + ": token " + index + " '" + value + "' read as " + prefix.group() + ", as the client does"); + return Integer.parseInt(prefix.group()); } public float floatToken(int index) { String value = token(index); + Matcher prefix; try { return Float.parseFloat(value); } catch (NumberFormatException e) { - throw error("token " + index + " '" + value + "' is not a number"); + prefix = floatPrefix.matcher(value); } + + if (!prefix.lookingAt()) + throw error("token " + index + " '" + value + "' is not a number"); + + Logger.warn(location() + ": token " + index + " '" + value + "' read as " + prefix.group() + ", as the client does"); + return Float.parseFloat(prefix.group()); } // The client reads a missing trailing number as 0 (String::ToInt on an empty token) diff --git a/src/engine/server/world/WorldServer.java b/src/engine/server/world/WorldServer.java index 0b200472..5c4edebb 100644 --- a/src/engine/server/world/WorldServer.java +++ b/src/engine/server/world/WorldServer.java @@ -43,10 +43,12 @@ import engine.parsers.objects.wpak.CompoundCurveTemplate; import engine.parsers.objects.wpak.EffectTemplate; import engine.parsers.objects.wpak.ModTypeTemplate; import engine.parsers.objects.wpak.PowerActionTemplate; +import engine.parsers.objects.wpak.PowerTemplate; import engine.parsers.wpak.CompoundCurveParser; import engine.parsers.wpak.EffectParser; import engine.parsers.wpak.ModTypeParser; import engine.parsers.wpak.PowerActionParser; +import engine.parsers.wpak.PowerParser; import engine.powers.RunePowerEntry; import engine.powers.RuneSkillAdjustEntry; import engine.server.MBServerStatics; @@ -374,6 +376,14 @@ public class WorldServer { exit(1); } + Logger.info("Loading Powers"); + PowerTemplate._powers = PowerParser.parsePowersFile(); + + if (PowerTemplate._powers == null || PowerTemplate._powers.isEmpty()) { + Logger.error("ABORT! Powers failed to load!"); + exit(1); + } + Logger.info("Loading PromotionClasses"); PromotionClass.LoadAllPromotions();