After the texture pass, our cave level was down from 1.3 GiB to about 946 MiB and I expected the next win to be another art problem. It wasn't. Once the oversized textures stop dominating, the size map stops being an inventory of big assets and becomes what it always was: a reference graph. The biggest remaining block in the project wasn't a big asset at all. It was a behaviour tree decorator.
BTD_HasOpponent is a decorator. Its entire job is to answer whether the AI currently has someone to fight. It should be a few kilobytes. It was pulling in 137 MiB, and the thing it was pulling in was the player character, which at that point still dragged the HUD, the death screen and everything those referenced behind it.
A cast to a Blueprint is a hard reference
The cause was three nodes. Not three systems, three nodes: `Cast To BP_PlayerSlime`, one in the decorator and two in the AI controller. Casting to a Blueprint class hard-references that Blueprint and its entire subtree, because the engine cannot perform the cast without the class being loaded. Casting to a native C++ parent costs nothing, because the class is already in the binary.
So the shape of the problem was: loading any AI loads its behaviour tree, which loads the decorator, which loads the player, which loads the death screen, which loads a 7680×4320 texture. A wandering slime in a corridor paid for a screen the player might see twice in a playthrough.
What made it worth writing down is why the casts were there. All three exist to read one variable, `bIsInvisible`, and that variable is Blueprint-only on BP_PlayerSlime. There was no native property to cast to. Someone needed a boolean, the only way to reach it was through the Blueprint class, and the cost was invisible at the time it was written. That is not carelessness. That is what a hard reference does: it charges you later, somewhere else, in a number nobody connects back to the line that caused it.
Two fixes, and why I took the cheaper one
Lifting `bIsInvisible` into C++ on the native parent is architecturally the right answer. The casts become native, cost nothing, and the flag lives where the rest of the character state lives. It also costs a build, an editor restart, and a migration of every Blueprint that already reads or writes the variable, while other people are working in the project.
The other option is a Blueprint interface with a single `GetIsInvisible()` function. An interface call references only the interface asset, which is a few kilobytes, and never the implementing class. No build, no restart, no migration. I took the interface, because the win was identical and it did not stop anyone else working. The C++ version is still the better end state, and it's on the list.
The trap I nearly walked into
A cast node does two things at once, and it is easy to only notice one. In BTD_HasOpponent the cast was a type test whose success and failure pins routed to different function results; in the two controller graphs the boolean output fed a knot. Replacing the cast with an interface call had to preserve both the value and the branch. Swap only the property read and you get a decorator that quietly always returns true.
Where I was wrong
I had written a rule in my own notes: a hard reference is free when the referencer only ever loads somewhere the target is already resident. That rule is correct. My application of it was not.
I had listed the player's skill components as free hard references, reasoning that they only exist on the player, so they never load independently. Then the AI decorator fix landed, the size map re-routed, and there it was again: the same 137 MiB, arriving through a different door:
The AI uses the player's skills. Of course it does; that is the whole point of building them as generic components. It is a design decision I made deliberately and then forgot when reasoning about load order. So the skill component loads with every enemy, independently of the player, and drags the player subtree behind it. My assumption cost a whole extra round of work to discover.
"Only exists on X" is a claim about load order, not a fact about ownership. Check it with get_referencers instead of reasoning about it.
The audit after that was less clever and more useful: every skill component the AI can reach, every cast, and whether the cast result is actually used. Two were pure type tests and could be swapped immediately. Eight genuinely reach into the player, reading Blueprint variables and in one case calling a Blueprint function on it. No reference swap removes those. The members themselves have to move first, which puts them behind the same build-and-restart cost I dodged earlier.
What's still open
SlimeShot, the largest at 130 MiB, is clear. Clone, DestinedComet, Grapple and GroundSlam still keep the player in their load chain, so the AI subtree has not fully collapsed. There is also a fourth hard reference, an overlap filter set to BP_PlayerSlime_C, where the obvious fix is wrong: the ally slime derives from the same native base, so widening the filter would silently make enemies overlap-detect allies. That one needs a gameplay decision, not a refactor, and I would rather leave it visible than change behaviour under the cover of a cleanup.
If you take one thing from this: run the size map on something small that loads early, not on your biggest asset. The expensive things in an Unreal project are rarely large. They are small things holding a reference to something large.