How it works · write-up
Xenon 2 scripted sine-motion bytecode
Scope and evidence
This document describes the small movement bytecode interpreted by
ScriptedSineMotion_UpdatePosition_FUN_00009aca. It is not the animation-script
format and it is not 68000 machine code. Level spawn records select one of these
motion scripts, and many enemy update procedures call the shared interpreter once
per canonical game frame (one game update per four VBLs).
The layout and opcode behavior below come from the 68000 instructions at
$9ACA..$9C7F, not from decompiler output. The implementation has also been checked
against consecutive canonical frames with xenon_tools/validate_scripted_motion.py.
The current Level 4 RAM image is stored in ReVa as /level4-live/mydumpat0.
It was imported from the 1 MiB canonical-frame dump
xenon_tools/run_logs/level4-live-0827.ram at game frame 63485. The dump remains
an ignored run artifact; the analyzed and annotated ReVa program is the durable
reverse-engineering copy.
Main entry points
| Address | Symbolic name | Purpose |
|---|---|---|
$9A40 |
ScriptedSineMotionPeriodicSpawner_Update_FUN_00009a40 |
Ticks the object's animation, calls $9ABE, then optionally performs the independent periodic projectile/spawn behavior stored in bytes $5E/$5F. |
$9A44 |
entry inside $9A40 |
Same path without the animation tick. Vector thunk $E8A enters here. |
$9ABE |
ScriptedSineMotion_UpdatePositionAndCollisionBounds_FUN_00009abe |
Calls $9ACA, then derives the interaction rectangle from the current sprite unless the object has terminated. |
$9ACA |
ScriptedSineMotion_UpdatePosition_FUN_00009aca |
Executes the movement integration and command interpreter. |
$9B74 |
ScriptedSineMotion_CommandDispatchTable |
Six signed handler displacements indexed by the even opcode word. |
$9C80 |
SineTable_8bit_00009c80 |
256 signed bytes used for Y; X uses the same table at phase + 64. |
The common $E1E update-procedure thunk enters $9A40. Several level overlays
install their own update procedures but still call $9ACA; the Python set
XENON_SCRIPTED_SINE_MOTION_UPDATE_PROCS lists the currently verified users.
Objects that merely copy another object's position, such as the $E36 follower,
must not be decoded as independent motion scripts.
Object state
A0 is a 98-byte MyObjectEntry. These offsets are members of
MyObjectTypeState.scriptedSineMotion when the owning update procedure uses the
interpreter:
| Object offset | Shared symbolic name | Representation and meaning |
|---|---|---|
$20 |
x_fixed |
Signed 16.16 X accumulator. The integer high word is the object/render/collision anchor. |
$24 |
y_fixed |
Signed 16.16 Y accumulator. |
$28 |
remaining_substeps |
Positive movement substeps remaining in the current sine segment. Zero dispatches a command. A negative value is an unfinished opcode-4 delay carried into later frames. |
$2A |
phase |
32-bit phase accumulator. Its low byte indexes the sine table. |
$46 |
script_start |
Base address used by opcodes 6 and 8. |
$4A |
script_cursor |
Address of the next command to dispatch. |
$4E |
phase_delta |
Signed 32-bit phase step. Opcode 2 loads its signed word operand shifted left by eight. |
$52 |
phase_acceleration |
Signed 16-bit amount added to phase_delta after every movement substep. |
$54 |
substeps_per_frame |
Interpreter budget for one canonical game frame. Zero makes $9ACA return immediately. |
Bytes $5E/$5F are not part of the bytecode interpreter. $9A40 aliases them as
periodicSpawnAccumulator and periodicSpawnRate; other update procedures reuse
the same union bytes for unrelated state.
Execution order
flowchart TD
A[canonical object update] --> B[load substepsPerFrame]
B -->|zero| R[return]
B --> C{remainingSubsteps}
C -->|negative delay| D[add delay carry to frame budget]
C -->|positive| M[integrate movement substep]
C -->|zero| F[fetch word at scriptCursor]
D -->|budget still <= 0| R
D -->|budget > 0| F
F --> H[dispatch opcode 0/2/4/6/8/10]
H -->|command leaves budget| C
M --> C
Commands do not consume a whole game frame. Opcode 2, 6, 8, or 10 can dispatch and continue using the unused substep budget in the same update. Opcode 4 is the only command that deliberately consumes that budget without moving.
Instruction set
All words are big-endian. Branch offsets are signed and relative to
script_start, not to the current command.
| Opcode | Shared symbolic name | Size | Operands | Exact behavior |
|---|---|---|---|---|
0 |
XENON_MOTION_SCRIPT_OPCODE_TERMINATE |
2 | none | Marks this object for destruction. If object $34 indicates a linked group, it walks the companion chain through $56/$5A and terminates every member. There is no continued trajectory. |
2 |
XENON_MOTION_SCRIPT_OPCODE_SINE_SEGMENT |
10 | u16 phase, s16 phaseDelta, s16 phaseAcceleration, s16 duration |
Loads the four motion fields, advances the cursor by 10, and immediately starts integrating. The stored phase delta is phaseDelta << 8. A usable segment has positive duration. |
4 |
XENON_MOTION_SCRIPT_OPCODE_DELAY |
4 | s16 delaySubsteps |
Advances the cursor by 4 and subtracts the operand from the current frame budget. If no budget remains, the zero/negative result is saved in remaining_substeps; later frames add their budgets until the delay expires. |
6 |
XENON_MOTION_SCRIPT_OPCODE_RANDOM_JUMP |
18 | eight s16 target offsets |
Uses the game RNG masked with $000E to select one of eight offsets. Negative entries are rejected and rerolled. Sets script_cursor = script_start + selectedOffset, then dispatches again. |
8 |
XENON_MOTION_SCRIPT_OPCODE_JUMP |
4 | s16 targetOffset |
Unconditional script_start-relative jump followed by immediate redispatch. This commonly implements backward loops. |
10 |
XENON_MOTION_SCRIPT_OPCODE_METADATA |
6 | two uninterpreted words | $9ACA ignores both words, advances the cursor by 6, and redispatches. In level path tables these words encode the initial X/Y anchor consumed by the spawn setup/capture code. |
The dispatch table has no bounds check. Odd words or even values greater than 10 would index unrelated memory and are not valid script instructions.
Sine integration
For each movement substep, $9B0E..$9B34 performs the equivalent of:
angle = phase & 0xff
y_fixed += signed8(sine[angle]) * 1024
x_fixed += signed8(sine[angle + 64]) * 1024
phase = advance_using_swapped_words(phase, phase_delta) & 0xffff00ff
phase_delta += phase_acceleration
remaining_substeps -= 1
The table values range approximately from -63 to +64. The exact table matches
int(sin(index * 2*pi / 256) * 64 + 0.5); using floor for negative entries is
wrong. x_fixed and y_fixed wrap as signed 32-bit values.
Prediction must report displacement between the integer high words, not the raw
fractional delta. Rendering and collision consume those integer anchors. Translating
an already integer interaction rectangle by the fractional delta introduced nearly
two pixels of error at truncation boundaries; Level 4 validation reduced 1,940
one-frame $E1E comparisons from 749 failures to zero by using high-word
transitions.
Scroll-spawn records and scripts
ScrollTriggeredSpawnDispatch_FUN_00003dc2 walks level-owned 14-byte records.
These records are descriptors, not bytecode:
| Record offset | Meaning |
|---|---|
0 |
signed scroll trigger |
2 |
enemy type |
4 |
object count |
6 |
one-based path/script index |
8 |
signed member spacing |
10 |
random seed/state value |
12 |
scripted substeps per frame |
The level's path-pointer table resolves pathIndex to script_start. If the first
instruction is opcode 10, its two words provide initial X and Y and the initial
cursor begins six bytes later. The forward/backward spawn tables and their pointer
tables are level overlays, so their addresses must be read from live RAM rather than
hardcoded across levels.
Capture and predictor contract
The frame stream includes the complete 98-byte object plus 128 bytes beginning at
script_start for verified scripted-motion update procedures. Capturing from the
start, rather than only from the live cursor, allows opcode-8 backward loops to be
executed externally. Pending scroll-spawn descriptors include 64 bytes beginning at
their initial cursor so an upcoming path can be inspected before object allocation.
world_state.py exposes the fields as ScriptedSineMotionState.
autoplay.py::_scripted_displacement_series executes the deterministic opcodes and
integration. validate_scripted_motion.py compares its one-frame result with the
next recorded canonical object anchor.
Opcode 6 is the currently nondeterministic boundary for offline prediction because
the relevant game RNG stream is not included with each object observation. When a
random branch is reached, the predictor retains the last exact tangent instead of
inventing a branch. The earlier 128-byte capture is NOT sufficient for Level-5
post-shop trains: the loop at $7187C..$719BB is320 bytes. Their observations now
carry512 script bytes (raw envelope610, or708 with the98-byte pre-update record).
Other objects retain128 script bytes. An out-of-window cursor must never be reset
to offset zero; finish only the known current segment and use the existing
unsupported-transition fallback when the missing command is needed.
Level-5 post-shop path trains (2026-09-02)
Use ReVa /level5dump, not /mydumpat0: $4F4DA has different bytes in the latter.
Level5PathTrain_Update_FUN_0004f4da is named and annotated in that Level-5 program.
The shared C/Python symbol is XENON_UPDATE_PROC_LEVEL5_PATH_TRAIN.
Each member independently runs $0F08->$9ABE->$9ACA, with a delayed initial
remaining-substep count. There is no predecessor-position link. The wrapper adds
$CD8 to screen Y first, so the existing script displacement is already a world
displacement; adding camera velocity again is wrong. Direction octants select
animations through $4F4BA. Every member has its own ordinary $32 health
($0E2A hit callback;20 HP in the first observed train).
The byte accumulator at object+$5E adds overlay byte$4F079; carry emits eight
radial $4180 shots, directions7..0, with scale$4F07A and sprite selector$4F07C.
Thus killing only the leading member does not stop the remaining members firing.
The current predictor models the path and already-emitted directional projectiles;
it does not yet schedule these train-specific future radial volleys.
Run214 verified2897 one-frame transitions,2687 eight-frame,1967 thirty-two-frame
and1057 sixty-four-frame predictions with zero integer world-coordinate error.
These checks skip members whose updater changed after destruction. Use
validate_scripted_motion.py RECORDING --tolerance 0.01 for the one-frame check.
Shared names
The authoritative constants are in xenon_tools/xenon_symbols.py. The C capture
uses the same XENON_* vocabulary in src/xenonControl.c, and the ReVa programs
label the dispatch table and handlers with ScriptedSineMotion_Opcode* names.
When a new opcode or interpreter user is verified, update all three locations and
add a recorded transition to validate_scripted_motion.py before changing tactics.