‹ XENON 2How it worksPosition-update mechanisms
Ghidra trace · program /mydumpat0 · 68000:BE:32

How a Position Gets Updated

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.

Swarm 0x00050050 Boss 0x000505aa Generic 0x00004ea0 Radial burst 0x0005025a Attachment 0x0000613e Horizontal projectile 0x00004180
rev.2 — adds the vector-table static sweep: 3 new position-update mechanisms, the animation-sequencer, the hit-reaction/damage family, and the dual-RNG-stream spawner. One correction folded in: boss-segment proc3 was mis-resolved in an earlier pass (see §10).
01

Pick a mechanism

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.

swarm · playing

table read every frame from &DAT_00004fd8/fda, indexed by (direction & 7) << 2 — active row highlighted live
02

Chain topology — swarm vs. boss body

Both 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.

SWARM — SpawnEnemyFormation_FUN_00050050 LEADER F1 F2 F3 … F9 F10 MARKER0xbc TARGET0x56 linked via next (0x12) · F1 has a distinct sprite (marker/lead frame) from F2-F10 (shared sprite) · leader is scroll-locked, not independently moving · leader also calls a still-unpinned RNG helper (thunk_FUN_00009aca) every frame BOSS BODY — BossSegmentedSpawn_Init_FUN_000505aa ("Kill the Eyes") CONTROLLER 0x50 S1 S2 … S7 S8head linked via a private predecessor field (0x1C), NOT the shared next/prev · S1 anchors to controller at fixed screen X, scroll-adjusted Y · each segment's proc3 = one-hit-kill (§08) — shoot any segment once, it dies
03

Decoded formation table — DAT_0004fee6

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.

confirmed — dereferenced and matched to a known offset
entry+0 (prefetch)+4 → proc3+8 → sprite
04

Generic object velocity table — DAT_00004fd8

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.

dircompassdxdy
05

Pattern-select table — DAT_0004ff70

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
06

The full roster

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.

addressnamemechanismflagsdemo
07

Shared building block — the animation sequencer

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.

AnimationSequence_Tick 0x000034c2

idle

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.

08

Shared building block — the hit-reaction family

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.

Single object 0x00006240

health100

Flash + subtract damage from its own counter (offset 0x32); explodes at zero.

Compound object 0x00006160

shared health (chain of 4)100

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.

One-hit-kill 0x00006208

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.

Flash-only 0x000061fe

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.

Damage + score 0x000063a8

health100
score accumulator (DAT_00000c92)0

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.

Self/children destroy 0x000034fa

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.

Silent pair despawn 0x00004020

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.

09

Shared building block — periodic spawner & two RNG streams

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.

Percent-chance accumulator byte[0x2f] += byte[0x5f], fires on 8-bit overflow

accumulator0 / 255
—
stream A · active player
stream B · other player

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.

10

Open questions

resolvedBoss 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.
resolvedVector-table coverage — every one of the ~32 previously-unexplored slots in the shared 56-slot trampoline table has now been decompiled. Net: ~9 are genuine per-object procs with zero static call-graph edge (same "invisible to Ghidra" profile as the horizontal-projectile trajectory); the rest are the shared helpers documented in §06–09.
resolvedFormation shape source — the two per-follower address fields are confirmed: proc3 (init snippet) and sprite pointer, not formation-layout data.
Which context swaps in stream B — confirmed the RNG-stream-swap mechanism exists (§09), not yet confirmed which concrete call site is "the other player's turn" versus something else entirely (e.g. a background/inactive object category). Needs a live 2-player capture.
Hit-reaction family: 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.
0x56 dual role — confirmed used both as a single target pointer (attachment/follow, §01) and as a private child-list head (compound hit-reaction, §08) — still not confirmed which concrete spawned object plays which role in practice.
Duplicate function bodies — 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.
Formation shape variety — only one 10-follower table was found, always read from the same fixed address with no write sites. Whether other shapes exist elsewhere, or variety comes entirely from the pattern-select LFSR, is still unconfirmed.