Ungodly Liquidation Leveling
Three bugs in the game's levelling code
These are defects in Liquidation RTS itself, not in the plugin. Each was found by reading the game's own decompiled code, each is described below with the line that causes it, and each is a switch you can turn off if you would rather have exactly vanilla behaviour.
Two of them are visible in ordinary play. The third costs you nothing but performance.
#The three, in one screen
| # | Bug, and where it lives in the game | What you notice |
|---|---|---|
| 1 | The experience cap is one level too low
Experienced.AddExperiencePoints |
Level 10 cannot be reached, and progress toward it is discarded rather than capped. |
| 2 | Landing exactly on a threshold does not promote you
CheckOnLevelUp |
The promotion test is strictly greater than, so hitting the requirement precisely falls one point short. |
| 3 | PromotionService leaks observers into an empty handler
PromotionService |
Subscriptions accumulate and are never removed, and the handler they feed does nothing. |
#How the stock system works
The three bugs make more sense once you know what they are sitting inside. All of this is the unmodded game.
Experience only comes from killing blows
Awards originate in GameEventsManager.EntityKilled. The killer takes half; the other
half is divided among nearby allies. Nothing else in the stock game grants experience at all, which
is why a unit that soaks damage all match without securing a kill never levels.
"Nearby" is a fixed radius, hard-coded at 1,310,720
in the simulation's units. The simulation runs on 16.16 fixed point, where
65,536 represents 1.0 — so that radius is
20 world units.
The curve is flat, and rebuilt for every unit that spawns
Level i costs i × 200, and Experienced constructs a fresh list of
those thresholds inside Awake — once per unit, every time one spawns. MaxLevel
is 10.
That flat curve is what the plugin's ten selectable algorithms replace, and the fact that every threshold is an exact multiple of 200 is what makes the second bug below reachable rather than theoretical.
Heroes get points; ordinary units get a number
Heroic.LevelUp grants one skill point and 7 attribute points, spent automatically as
2 Constitution, 2 Dexterity, 2 Intelligence and 1 Stamina. Those attributes convert to real stats
through these constants:
| Attribute | Grants | Raw (16.16) | Actual value |
|---|---|---|---|
| Constitution | Health per point | 2,293,760 | 35 HP |
| Stamina | Armour per point | 10,922 | 0.1667 |
| Stamina | Regeneration per point | 229,376 | 3.5 |
| Intelligence | Energy per point | 983,040 | 15 |
| Intelligence | Energy regen per point | 196,608 | 3 |
| Dexterity | Attack speed per point | 1,310 | 0.02 |
| Dexterity | Damage per point | 65,536 | 1 |
The right-hand column is the raw constant divided by 65,536, calculated by this page rather than typed in a second time.
Ordinary, non-hero units go through PromotionService instead — which is the third
bug.
#Bug 1 — The experience cap is one level too low
Level 10 cannot be reached, and progress toward it is discarded rather than capped.
The code
if (Level >= MaxLevel - 1)
{
Experience = 0;
return;
}
This is the first thing the method does, before any award is considered.
What it does
The guard tests against MaxLevel - 1 rather than MaxLevel. With the stock cap of 10 that makes the condition Level >= 9, so every award handed to a unit that has reached level 9 is refused.
And it is worse than it first looks
It does not simply refuse the award. It sets Experience = 0 on the way out, so whatever the unit had already banked toward the next level is discarded rather than held at the cap. A level 9 unit is not paused at 9 — it is permanently reset to zero progress every time it earns anything.
What you see in game
Level 10 is unreachable through normal play. The game ships a Steam achievement, HERO_LEVEL_10, for reaching it.
What the plugin does instead
The replacement accepts experience up to the real cap, so level 10 is reachable, and stops accumulating only once the cap has actually been reached.
#Bug 2 — Landing exactly on a threshold does not promote you
The promotion test is strictly greater than, so hitting the requirement precisely falls one point short.
The code
Experience > LevelExperience
The comparison that decides whether a unit is promoted.
What it does
The test is strictly greater than. A unit whose experience is exactly equal to the requirement for the next level fails it, and stays where it is.
And it is worse than it first looks
Stock thresholds are exact multiples of 200 — level i costs i × 200 — so an award that lands precisely on one is an outcome the numbers actually allow, not a theoretical edge case.
What you see in game
The unit sits one point short of a level it has already paid for, until the next award pushes it past. Nothing tells the player this has happened.
What the plugin does instead
The comparison becomes greater than or equal, so meeting the requirement is enough.
#Bug 3 — PromotionService leaks observers into an empty handler
Subscriptions accumulate and are never removed, and the handler they feed does nothing.
The code
OnEnable() -> subscribes to the level-up notification
OnDisable() -> empty
OnLevelUp() -> empty
Promotion1, Promotion2, Promotion3 -> never read
The shape of the class, rather than a single line.
What it does
The service subscribes to the level-up notification in OnEnable, but its OnDisable is empty. Nothing ever unsubscribes, so every re-enable adds another live subscription on top of the ones already there.
And it compounds
The handler those subscriptions feed is empty too, and the three ModifierConfig fields the class carries — Promotion1, Promotion2 and Promotion3 — are never read by anything. Each leaked subscription is pure cost delivering a notification to a method that does nothing with it.
What you see in game
Two things at once: a subscriber list that grows for the whole session, and ordinary units that gain a level number and no benefit whatsoever from it.
What the plugin does instead
The plugin unsubscribes properly, and grants real per-level bonuses through the game's own AbsoluteRelativeModifier system — which means the existing tooltip, save and modifier-display code all see and report them correctly.
#Checking whether any of it mattered in your match
The plugin does not ask you to take its word for it. Its Diagnostics tab counts how many times each of the three fixes actually changed an outcome during the match you just played.
That number is the honest test of whether a fix is doing anything for you. A campaign where no hero ever reached level 9 will show nothing for the first fix, because there was nothing to repair — and that is the correct reading, not a fault.
#Why the fixes are integer-only
Worth knowing if you play online, because it explains a design decision that looks like overkill until you see the reason.
Liquidation RTS is a deterministic lockstep game: every machine re-runs the whole simulation from the same command stream and compares a checksum each tick. Anything that feeds a unit's stats has to produce a bit-identical result on every machine, or the match desyncs.
So none of these fixes use floating point. Every formula in the plugin is written with only
+ - * / and >> on System.Int64, which ECMA-335 defines
exactly. The three functions that genuinely need a transcendental are provided as integer routines:
square root by Newton's method, base-2 logarithm by bit scan, and sine from a 91-entry table.
Percentages are applied as integer multiply-then-divide, and stat bonuses reach the game's 16.16
fixed point by shifting, never by casting a float.
Selecting the Stock curve reproduces the unmodded thresholds exactly — verified programmatically against i × 200 rather than assumed.