One line per message, UTF-8, \n-terminated. Deliberately not JSON: the JDK
ships no JSON parser, and a flat line stays readable in a terminal — which is
most of what this layer does early on.
agent (Frida, inside the game)
│ frida message
▼
protocol.exe (Rust) ──<base>.in ──► java.exe: zygote + mods
◄─ <base>.out ──
The host owns both ends. The agent never talks to the JVM directly.
Those two are Windows named pipes the host creates before it starts the JVM,
under a random \\.\pipe\ancaria-<128 bits> base it passes as --pipe. One
per direction, because a synchronous handle serialises its operations and a
parked read would hold up every write. The JVM's own stdin, stdout and stderr
carry no frames and belong to the mods.
Frames travelled on the JVM's standard I/O until 0.100.x, and still do when no
--pipe is given, which is what the test harnesses use. That arrangement puts
the wire somewhere a mod can print into: see Main.claimStdout in coderpack.
| Frame | Direction | Meaning |
|---|---|---|
EVT <seq> <type> <k>=<v> ... |
host → Coderpack | happened, no answer expected |
ASK <seq> <type> <k>=<v> ... |
host → Coderpack | about to happen, answer required |
END <seq> ok=1 |
Coderpack → host | let it proceed unchanged |
END <seq> cancel=1 |
Coderpack → host | suppress the write |
END <seq> set.<k>=<v> ... |
Coderpack → host | rewrite these fields, then proceed |
CMD <seq> <name> <k>=<v> ... |
Coderpack → host | do something / read something |
RES <seq> ok=1 [<k>=<v> ...] |
host → Coderpack | command result |
RES <seq> err=<text> |
host → Coderpack | command failed |
LOG <level> <text> |
both | diagnostics |
BYE |
host → Coderpack | shutting down |
seq is decimal, monotonic, unique per session. EVT/ASK and CMD draw
from separate counters — they flow in opposite directions.
Values are percent-encoded only for bytes that would break the format:
%20 space, %3D =, %0A newline, %25 %. Everything else is literal.
ASK is valid only where the hook sits before the write and owns a
register we can rewrite. That is a property of the game's code, not a design
choice:
| Event | Frame | Why |
|---|---|---|
health.damage |
ASK |
+0x16FC44 commits EAX, which we own |
exp.gain |
ASK |
+0x17EAE4 commits EAX, gain in ESI |
gold.delta |
ASK |
the AddGold entry owns the delta argument |
item.pickup |
ASK |
the pickup function reads the item from its ref argument |
skill.change |
ASK |
+0x1827DA commits the low byte of ECX |
attr.spend |
ASK |
no register to swap, see below |
health.death |
EVT |
the source register is a zero constant the path reuses |
entity.* |
EVT |
observation only, no rewrite point identified |
item.stored / item.equip / item.moved |
EVT |
no clean no-op path: on equip, ref 0 means unequip, not do nothing |
level.changed |
EVT |
the level is anti-cheat mirrored and drives grant tables |
every *.changed |
EVT |
already committed |
A verdict does not have to be a register swap. attr.spend is asked after
the game's own grant returns, and the agent applies the answer by writing the
field. Nothing else has observed it yet, so the result is the same. What makes
an event askable is that Coderpack's answer can still decide the value, not the
particular way it is applied.
item.pickup is the clearest case of that. The game's pickup function resolves
its ref argument through the object table and, when the lookup fails, jumps to
its own epilogue having done nothing. So cancel writes 0 into that argument,
the game's own "nothing there" path, and set.ref writes a different one,
which makes the same code pick up a different object. Only the hero's pickups
are asked about. A creature picking something up arrives as EVT with
player=0, because the rate on those three call sites has never been measured
and an ASK stops the game thread.
The verdict carries a second, different power. set.ref chooses which
object is picked up and lasts for that one call. set.type (and set.level,
set.min, set.atk, set.pct) rewrite the object itself, and outlive the
event: drop the item again and it is still what it was made into. They compose
in that order: the ref picks the object, the rest reshape whatever was picked.
set.type is the item's label, not its identity. Confirmed in game, it
renames and redraws and changes nothing else. What an item does is set.mods,
the modifier list (id:value pairs), which is why a retyped rune keeps
upgrading the art it always did. Writing modifiers is implemented and not yet
verified against the game. The display name cannot be written at all:
Sacred composes it from affixes at draw time.
Sending ASK for anything else is a bug: Coderpack answers END ok=1 and logs it
rather than pretend the verdict mattered.
CMD is how a mod acts on its own initiative. The host turns it into a
NativeFunction call or a memory write inside the agent, on the game's
thread, and answers with RES.
CMD 7 player.teleport x=222850 y=137608
RES 7 ok=1
CMD 8 player.gold value=104233
RES 8 ok=1 gold=104233
Implemented so far: player.state, player.teleport, player.hp,
player.exp, player.gold, ui.string, type.name, type.find,
type.list, item.info, item.reshape.
RES fields are flat, like every other frame. Nesting them looks like it
works and does not: the host turns an inner object into one field holding JSON,
so Coderpack looks a value up by name and finds nothing, with no error anywhere. That
is how ui.string returned null for its whole life.
type.find and type.list are the reverse of type.name, which the game has
no primitive for, so the agent builds the index by asking typeName for every id
once, on first use. A mod should always name a type rather than write its id:
ids are build-specific numbers with no meaning, and
TYPE_OBJECT_POTION_LARGE_RED survives a build that renumbers them.
CMD 3 type.list prefix=TYPE_OBJECT_POTION_
RES 3 ok=1 n=24 types=TYPE_OBJECT_POTION_SMALL_RED:5171,...
CMD 4 item.reshape ref=1083 type=5100
RES 4 ok=1 ref=1083 type=5100 name=TYPE_OBJECT_POTION_LARGE_RED
A command naming a live object takes its object-manager ref, never a raw
address — heap addresses move every launch.
ASK blocks the game thread until the verdict arrives, so it is used only at
sites measured cold. From this project's measurements: the damage writer peaks
at ~5 calls/s in real combat, a rune invest is one call per click, a kill is
one call per mob. The regen writer (~10/s per creature) is never exposed.
Frida's recv().wait() has no timeout, so the timeout lives in the host: if
Coderpack does not answer within the deadline the host replies END ok=1 itself and
marks the mod slow. The agent always unblocks. The agent trusts the host;
the host trusts nothing.
The EVT queue is bounded. If Coderpack falls behind, EVT frames are dropped and
counted (the count is reported as a LOG). ASK frames are never dropped.
EVT 1 session.world_loaded
EVT 2 hero.captured class=9 className=Daemon level=142 hp=27127 maxHp=27127
ASK 3 health.damage entity=player damage=553 current=19849 next=19296 max=26999
END 3 set.next=19849
EVT 4 entity.death type=TYPE_NPC_SPIDER_M2 level=42 ref=903
ASK 5 item.pickup ref=8814 name=TYPE_OBJECT_RING_FIRE01 level=30 player=1
END 5 set.ref=8901
CMD 1 player.gold
RES 1 ok=1 gold=104233