← Back to workCase Study · Reverse Engineering
Anima
Palworld's player weapons have no elemental system — not undocumented, absent: 0 of 2,466 items carry one. Anima adds it to a closed, shipping game binary with no source and no documentation — and a build gate that refuses to compile a name I can't prove. The game is the venue. The method is the point.
The actual job
There was no tutorial for this, because nobody had done it.
Most work has a path someone already walked. This had none. The target is a closed binarywith no source, no documentation, no support channel and no prior art — and the feature being added didn't exist anywhere in the game to copy from. The first real finding was that absence itself, established by measuring all 2,466 items and finding not one carrying an element.
That is the shape of the problems worth hiring someone for: the undocumented vendor API, the integration whose spec was lost, the legacy system whose author left. The question a client should ask isn't whether I've seen their exact stack before. It's what I do when the answer isn't anywhere— and whether I'll tell them the difference between what I proved and what I assumed.
The method
No source, no code — enforced by the build, not by willpower.
Work like this fails in one specific way: the codebase fills up with names that look right. A plausible guess runs, does something, and gets committed — and six weeks later nobody can tell which parts were verified and which were hopeful. The result looks finished and is built on sand.
So the rule is mechanical: every game-internal name in the codebase must trace to a datamine of my own build, with the specific names the code relies on committed as curated evidence. A checker runs in CI and fails the build on any internal-looking name that isn't in that evidence.
A guess cannot be merged quietly. Not “shouldn't be” — cannot be, because the build rejects it. Discipline that depends on remembering to be disciplined is not discipline; it's a good intention with a deadline attached.
That constraint paid for itself twice over. Forcing every game name behind a single seam is what keeps the core logic testable with the game absent — and what turns the next patch from a hunt into a one-file repair.
What that bought
Four decisions, each one confirmed in the running game before it counted.
StorageLet the game do the savingThe infusion payload rides a field the weapon already owns and the game already saves. No sidecar file, no custom save format, no migration story — and confirmed to survive save → quit → relaunch → reload, on that weapon instance only. The cheapest storage layer is the one someone else already maintains.
ElementHand the engine a real elementRather than reimplementing damage, a pre-hook sets the element on the struct the game feeds its own damage calculation — before it runs. So the game applies its own affinity rules: a fire-weak target takes more, a fire target resists. Nothing to keep in sync when the game rebalances, because none of it was copied.
DetourThe first road didn’t existMelee damage is built in native code — none of the reflected functions fire for a swing. That was established by testing eight of them and logging that all eight stayed silent, not by reading a doc that doesn’t exist. With no struct to modify, melee instead calls the game’s own damage entry with the same struct, so the game types the hit rather than the mod faking it with extra HP loss.
Blast radiusOff by default, alwaysBoth hooks fire for every attack in the world, so each one asks first: is this the player’s equipped weapon, and is it infused? No token, and the hook returns instantly with vanilla behaviour untouched. A mod that can’t be switched off is a mod that breaks somebody’s game.
The hard parts
Most of the work was deciding what not to trust — including my own code.
State, not events — so it can’t missThe trigger for earning an infusion is butchering a pal you own. That fires no hookable event on this build: five separate death paths and the record-setter were each tested and each stayed silent. Rather than picking the least-wrong event, the mod reads the game’s own lifetime counter every 2.5 seconds and grants on any increase. An event hook can miss. A counter you re-read cannot.
Assume your own scheduler diesThat poll rides a delayed-call chain, and that dispatcher was observed dying mid-session. So the combat and movement hooks double as a heartbeat: if the chain goes quiet, they poll inline and restart it. The failure was watched happening and designed around, which is the difference between a system that degrades and one that stops silently while looking fine.
I deleted two finished featuresAn extraction building and a forge screen were both built and both removed on 2026-08-09, because a custom station is a worse picker than the inventory the player already has open. Working code is not the same as the right answer, and the cost of keeping the wrong one compounds. The decision is in the repo with its date and its reasoning.
Installers shouldn’t guess eitherThe installer locates the game by walking Steam’s own library list rather than assuming a path — and refuses to guess: zero matches and several matches are both errors that name what was found and ask you to choose. It also cross-checks the game’s log to catch the case where files land in a folder the game never reads, which would make the next test measure an unchanged game.
Make the patch a one-file fixThe core logic knows nothing about the game it’s modding and is tested in plain Lua with the game absent. Every game-specific name lives behind a single seam file. When the next patch moves something — and it will — the repair is one file, not a hunt through the codebase. That is the difference between a project that survives its dependency and one that dies at the next update.
By the numbers
0 / 2,466Items in the game carrying an element before this — the feature was proven absent, not assumed missing
158Checks that run with the game not installed, across the core and every subsystem
10CI gates, including the one that fails the build on a name I can’t prove
13,991Lines of throwaway diagnostics written to map the engine — larger than the mod they produced
Status you can check
Two buckets, and I don't move things up early.
Every capability in this project sits in one of exactly two states, and the distinction is enforced in the docs the same way the naming rule is enforced in CI.
Confirmed — somebody watched it workObserved running in the live game and logged. Storage surviving a full relaunch, both damage paths carrying a real element, the effect staying on only the infused weapon, the earn loop, the apply gesture, the tooltip, and the permanence rule.
Wired — built, tested, but unwitnessedPassing every off-game check and not yet seen running in the game. Removal sits here today. It clears the same bar every confirmed item cleared before it moved up, and it stays in this bucket until it does.
Most status reports have one bucket, labelled done. That single bucket is where schedule risk hides — and on a fixed-price engagement it hides on the client's side of the invoice. Two buckets cost nothing to maintain and mean a progress report is something you can act on rather than something you have to discount.
Under the hood
The stack, by layer.
CoreGame-agnostic logic — payload model, token codec, effects, the damage and mitigation maths. Knows nothing about Palworld and is unit-tested without it installed.Lua 5.4
SeamOne file holding every game-internal name the mod touches, each traceable to a datamine. The only place a game patch can hurt.bindings.lua
RuntimeThe shipping mod, one subsystem per file: the two combat hooks, the apply surface, the tooltip line, the economy poll, and the removal primitive.UE4SS
DataItems, icons and display markers declared as data and regenerated by script from one source of truth, so the catalogue can’t drift from the code.PalSchema · JSONC
ToolingEleven generators and ten static checkers, plus an installer that detects the game, verifies where it landed, and supports a dry run and a clean uninstall.Python · PowerShell
CIEvery push runs the off-game suites and all ten gates — grounding, binding resolution, hook safety, global leaks, key-chord collisions, function arity.GitHub Actions
What it won't do — and what I won't claim
The limits are on the page because you'd find them anyway.
Dedicated-server co-op is untestedConfirmed in single-player and as a solo host. The apply flow runs per-client and should behave in co-op, but should is not a claim — nobody has run it on a dedicated server, so that is what the page says.
Uninstall is wired, not confirmedThe removal path is built and passes the off-game harness — snapshot, clear, rebuild, read back — but has not had its in-game confirmation pass. It sits in the lower bucket until someone watches it work, like everything else did before it moved up.
The version pins are exact, and that’s the pointA specific game build, a specific loader commit, a specific schema release. Modding a closed binary is brittle by nature; pretending otherwise just moves the breakage to the player. The pins are published, and the one rolling tag in the chain is flagged as the first suspect after any breakage.
The source is privateThis one is a closed repo, so the evidence on this page is the argument rather than a link to browse. The relationship to the game’s publisher is stated plainly in the repository’s notice: an unofficial, independent work that ships no game assets.
No documentation.
No guessing either.