Xenon 2 doesn't have one enemy-movement system — every object type installs its own
code in the same updateProc function-pointer slot, called once per
frame per object. A full static sweep of the game's shared "install proc by ID" trampoline table
(56 slots) turned up the whole roster below: chase-chains, a table-driven compass, a rigid
attachment, a table-driven pickup trajectory — plus the non-positional machinery every one
of them leans on (a sprite-animation sequencer, a hit-reaction/damage family, and a periodic
spawner that turned out to run on two separate, deliberately-isolated RNG streams).
Switchable, playable, with the real decoded data behind each.
proc3 was mis-resolved in an earlier pass (see §10).
Every one of these runs as an object's updateProc — there's no
separate "movement system," just different code installed in the same pointer slot. Shapes below
stand in for the game's real sprites. The rng tag on a
tab means that mechanism reads the game's pseudo-random generator every call — see
§09 for why that matters if you ever want to synthesize an extra frame between two real
ones.
&DAT_00004fd8/fda, indexed by (direction & 7) << 2 — active row highlighted liveBoth are "walk a chain of linked objects, each frame copy the neighbor's position" — but they use different link fields, and it changes what the shape means: the swarm's chain is single-file with a scroll-anchored point out front, the boss's chain is a segmented body anchored to a fixed on-screen X.
Fixed table, no write cross-references found anywhere — every swarm this spawner produces
uses these same 10 followers. +4 is proc3 (a small per-type
init snippet), +8 is the sprite pointer — and it
dereferences cleanly as an 8×7 SpriteData header, confirming
row 0 (the lead follower) really does carry different pixel data from rows 1–9.
| entry | +0 (prefetch) | +4 → proc3 | +8 → sprite |
|---|
No leader, no chain — EnemyUpdate_DiveAttackDespawn_FUN_00004ea0
just looks up (dx,dy) from an 8-entry table keyed by a 3-bit direction
field on the object itself and adds it to position every frame. It's a clean compass rose, ±2px
per axis — the same table shown live in the visualizer above.
The update-procedure address is a movement mechanism, not a complete gameplay type tag.
The reproducible first-level automation capture shows released collectibles using this same
routine with a no-op proc3 and pickup sprite ranges; hostile
classification must therefore include status/sprite/procedure context rather than treating
every 0x00004ea0 object as an enemy.
| dir | compass | dx | dy |
|---|
Indexed by a per-slot LFSR that rotates on every swarm spawn. Sixteen 12-byte records; the
spawner reads the first two fields — field +0 runs 0–15 in order, field +2 is
a shuffled permutation of the same range, reading as a shape/variant lookup. The LFSR's seed
constants live at 0x00000d5c, set in
LevelBossInit_KillTheEyes_FUN_000508a8 — the block of bytes
flagged early on as "possibly encryption" was just this.
| slot | +0 index | +2 shuffled |
|---|
Every confirmed updateProc from this session's full sweep of the
shared 56-slot vector table (ProcInstallVectorTable_00000e1e, full
per-slot table in XENON2.MD §2.10) plus the direct-install spawn code — not just the
ones that move an object, but the sentinel and lifecycle ones too, since an object's identity
over its lifetime is just "whatever's currently in this slot." rng
marks one that reads the pseudo-random generator on every call;
lag=0 marks a literal same-frame copy with no
smoothing at all; amber marks a correction made this session.
| address | name | mechanism | flags | demo |
|---|
Almost every updateProc above opens by calling the same subroutine,
FUN_000034c2, before doing its own position math. It's not a movement
routine at all — it steps a per-object sprite-animation script: a 6-byte record
{spritePtr, duration} repeated until a terminator record, whose
second field is read instead as a function pointer and called directly. That's how a
multi-frame death animation ends by actually destroying the object — the last "frame" of
the sequence is the destroy call. Its companion initializer,
AnimationSequence_Start (0x000034ea),
loads a script's first frame the same way. One real object type in the roster (§06) uses
nothing else: 0x00005fec is a genuine updateProc whose
entire body is a single call into the ticker below — no movement, no state machine, just
"advance my animation" every frame. A plausible fit: an idle decoration or ambient particle.
Each slot's number is its duration in ticks; the last slot (red) is the terminator —
reaching it invokes the completion callback rather than showing a frame. This script models a
4-frame explosion whose callback is DestroyObject_UnlinkAndFree_FUN_0000107c.
A cluster of small routines all reachable through the vector table, all reading a caller-supplied
damage amount and all funneling into the same terminal step
(SpawnExplosionEffect_TailIntoAllocator, 0x00003a5e).
They read a damage amount rather than being driven by the object's own state, so these most likely
run as proc3 (a collision-response callback) rather than the per-frame
updateProc — not yet confirmed against a live collision call site.
Two more family members aren't shown as their own card below because there's nothing to see:
0x0000634c and 0x00006250 are bare aliases
that skip the health check entirely and jump straight to the explosion — visually identical
to any of the cards below at the moment they die.
Flash + subtract damage from its own counter (offset 0x32); explodes at zero.
Damage forwards to self->target(0x56); every member
re-flashes on any hit; all destruct together at zero — the multi-hit-point model
for a shared-health body.
No health counter at all — flash, then unconditional destroy.
This is what every "Kill the Eyes" boss segment installs as proc3
(§02, §10): one hit ends that segment, regardless of the other seven.
No health field read or written at all — just installs the flash drawProc and returns. Never explodes, never dies. Best fit: an indestructible surface (a wall segment, a shield) or a cosmetic "that shot connected" acknowledgement.
Same shape as the single-object card, but on death it also adds self->0x42
to the running score total, and flashes via its own dedicated label
(&LAB_00006432) instead of the shared flash routine.
Two more updateProcs share this section's shape but read no damage input at all — they terminate an object (or a whole attached group) quietly, with no flash and no explosion, which is itself informative: it means whatever triggers them is a bookkeeping condition, not combat.
Reads a flag byte (offset 0x34): if clear, kills only itself; if
set, walks its own self->0x56/0x5a attachment chain and kills
every member. No SpawnExplosionEffect call either way — a
clean unlink, not a death.
If self and its
target(0x56) point at each other (a mutual pair), both are
unlinked straight back to the free list. Best-fit reading: the inert "weapon/attachment
placeholder pair" every formation spawn (§03) leaves behind once the swarm is gone.
FUN_00009a40 is a genuine updateProc
(confirmed — it reads the object's real sprite/x/y
fields) that rolls a percent-chance-per-frame spawn: an 8-bit accumulator gains
byte[0x5f] every call, and on overflow past 255 it spawns a new
object with a random sub-state. The interesting part is what calls it: two wrapper routines
explicitly save and swap the live RNG state pair around that call — strong evidence
the game runs at least two independent, separately-advancing RNG streams, most plausibly one per
player so the inactive player's background updates can't perturb the active player's random
sequence. This is the clearest concrete case found so far for why "just re-run an
updateProc to synthesize an extra frame" can silently desync the game.
A simplified, illustrative PRNG — not the game's real LFSR, whose constants weren't
extracted this pass. "tick (normal frame)" mirrors the plain per-frame call: stream A
advances. "run other player's tick" mirrors FUN_00009a0c's
save/swap/restore: it advances stream B only, and stream A is provably
untouched — watch its sequence not move.
proc3 mis-resolution — an earlier pass claimed boss-segment proc3 (vector 0x0f3e) resolved to thunk_FUN_00003af4, the collision-scan helper. Re-checked against raw memory this session: 0x0f3e actually jumps to 0x00006210 → 0x00006208, the one-hit-kill hit-reaction routine (§08). thunk_FUN_00003af4 lives at a completely different vector slot (0x0ed8). Corrected in the Ghidra database and cross-checked byte-for-byte.proc3 or updateProc? — the whole family in §08 reads an externally-supplied damage amount, strongly suggesting proc3 (collision response), but no live collision-detection call site has been located to confirm which register/slot actually invokes them.FUN_00003d86 (zero static callers) and FUN_00003d9c (one real caller) decompiled to byte-identical bodies this session. Possibly a genuine duplicate, possibly a tooling artifact — raw disassembly of both needs a direct comparison.