Initialize Movement for Attack
2025-03-30
This is a detailed look at how enemy movement is initialized. Including determining the target direction and distance. This specifically is tracing movement when the Enemy is trying to attack a target but is not close enough. The various “Wander” commands differ in same ways
The ApplyMovement Function moves enemies in a direction which is stored at $7E9786,X (Where X is the current enemy being processed). This will cover how those values are initialized in the case of an enemy attacking a target a close range.
If step by step analysis of SNES assembly isn’t what you’re interested in, skip ahead to the practical example at the end where we walk step by step through a battle.
Initialize movement variables
Ready to move timer
A few levels up in the call stack from the rest of the functions discussed here is the function that decides if an enemy is ready to move. Enemies have a movement timer stored at $7E98A0,X where X is the offset of the current enemy, this ticks down every frame and when it reaches 0 it resets and the function to intialize movement is called. This means that movement can only start on frames that are a multiple of the max movement timer.
For example, The Blue Imps in Truce Canyon have a max timer of 0x10, so they can only start movement every 16 frames. (This may be consistent across all enemies? To be determined).
At the beginning of a battle enemy 0’s timer is set to max, enemy 1’s timer is set to max + 1, etc. in order to stagger the movement timers for enemies. On subsequent resets each timer is set to the normal max.
CalculateDirection
CalculateDirection is a function that calculates an angle between two entities. The source X/Y is stored in $D3/$D4 and the target X/Y is stored in $D5/$D6 prior to calling the function.
The output is a direction in the A register and a copy in $DB. The direction is represented as between 0x00 and 0xC0
Note: The X/Y diff here is calculated as source_x - target_x and source_y - target_y. This results in a diff that can be applied, but its’ the diff that can be added to the target to get the source rather than the other way around.
As an example
source_x = 0x5
source_y = 0xA
target_x = 0x2
target_y = 0xC
source_x - target_x == 0x3
source_y - target_y == 0xFE (-2)
The final direction is still calculated from source->target, but this was something I found a bit confusing while working out what was happening here. (It’s been many years since I took Trig, this observation is probably obvious for someone who works with it regularly).
CalculateDirection:
; Calculate X vector
C10222 SEC
C10223 LDA $D3 ; Load source X coord
C10225 SBC $D5 ; Subtract target X coord
C10227 STA $D7 ; Store to temp
; This checks the sign of the previous subtraction by subtracting the carry from 0
C10229 LDA #$00
C1022B SBC #$00
C1022D STA $D8 ; Store sign bit to temp
; Calculate Y vector, same operations
C1022F SEC
C10230 LDA $D4
C10232 SBC $D6
C10234 STA $D9
C10236 LDA #$00
C10238 SBC #$00
C1023A STA $DA
; Calculate absolute X distance
; $DE = $distance ^ $sign
C1023C LDA $D7
C1023E EOR $D8
C10240 SEC
C10241 SBC $D8
C10243 STA $DE
C10245 STZ $DF
; Calculate absolute Y distance
; $E0 = $distance ^ $sign
C10247 LDA $D9
C10249 EOR $DA
C1024B SEC
C1024C SBC $DA
C1024E STA $E0
C10250 STZ $E1
; Use the previous calculations to lookup a angle value
; The two values are combined together into E2, which is then used to index into the lookup table
C10252 REP #$20
C10254 LDA $DE
C10256 LSR
C10257 LSR
C10258 LSR
C10259 STA $DC
C1025B LDA $E0
C1025D AND #$FFF8
C10260 ASL
C10261 ASL
C10262 CLC
C10263 ADC $DC
C10265 STA $E2
C10267 ASL
C10268 TAX
C10269 TDC
C1026A SEP #$20
C1026C LDX $E2
C1026E LDA $C0F300,X ; Load from lookup table
; Determine which quadrant the direction is in.
; If X/Y are both positive add 0x80
; If X/Y are both negative return as is
; If +X/-Y subtract value from 0x80
; if -X/+Y negate
C10274 LDA $D8 ; Check X sign
C10276 BMI $C1028C ; If X is negative, branch to $C1028C
C10278 LDA $DA ; Check Y sign
C1027A BMI $C10284 ; If Y is negative, branch to $C10284
; +X/+Y
C1027C CLC
C1027D LDA #$80 ; If both X and Y are positive
C1027F ADC $DB ; Add $80 to the lookup value
C10281 STA $DB ; Store the result and return
C10283 RTS
; +X/-Y
C10284 SEC
C10285 LDA #$80
C10287 SBC $DB
C10289 STA $DB
C1028B RTS
C1028C LDA $DA
C1028E BMI $C10296
; -X/-Y
C10290 TDC
C10291 SEC
C10292 SBC $DB
C10294 STA $DB
; -X/+Y
C10296 LDA $DB
C10298 RTS
A python equivalent of this function
def calculate_direction(source_x, source_y, target_x, target_y):
"""
Calculates the direction angle between two sets of coordinates.
Simulates the assembly routine at C10222.
Args:
source_x: X coordinate of the source point
source_y: Y coordinate of the source point
target_x: X coordinate of the target point
target_y: Y coordinate of the target point
angle_table: The lookup table from C0F300 (should have 1024 entries)
Returns:
Direction value (0-255, representing 0-360 degrees)
"""
# Calculate X difference with sign
x_diff = source_x - target_x
x_sign = 0 if x_diff >= 0 else 0xFF
# Calculate Y difference with sign
y_diff = source_y - target_y
y_sign = 0 if y_diff >= 0 else 0xFF
# Convert to absolute values (using EOR and subtraction like the original)
# Mask with 0xFF to simulate 8-bit registers
x_abs = ((x_diff ^ x_sign) - x_sign) 0xFF
y_abs = ((y_diff ^ y_sign) - y_sign) 0xFF
# Prepare the table index
x_component = x_abs >> 3 # Divide by 8
y_component = (y_abs & 0xFFF8) << 2 # Mask to multiple of 8, then multiply by 4
# Combine to form the final index
combined_index = (y_component + x_component) & 0x3FF # Limit to 10 bits (0-1023)
# Look up the value from the provided table
direction = angle_table[combined_index]
# Adjust based on quadrant
if x_sign == 0:
if y_sign != 0:
direction = 0x80 - direction
else:
direction += 0x80
else:
if y_sign == 0:
pass
else:
direction = 0x00 - direction
return direction & 0xFF
CalculateDistance
Finally, a comparatively simple function that calculates the distance between two entities
CalculateDistance:
; The A register holds the direction when entering this function
; First translate that direction into a lookup index
C101F9 REP #$20
C101FB ASL
C101FC ASL
C101FD AND #$03FF
C10200 TAX
C10201 LDA $C0F900,X ; Sine table lookup
C10205 AND #$00FF ; Only keep the low byte
C10208 CPX #$0200
C1020B BCC $C10211 ; If the index was >= 0x200
C1020D EOR #$FFFF ; Negate it
C10210 INC ; Add one for 2's complement
C10211:
C10211 STA $A7 ; Store the value to A7
C10213 LDA $AE ; Loads number of movement steps
C10215 AND #$00FF ; Only keep the low byte
C10218 STA $A5 ; Store in $A5
C1021A SEP #$20 ; Set 8-bit accumulator mode
; This function multiplies $A7 and $A5 and adds the result to $AA in 16-bit mode
; It then switches to 8-bit accumulator mode and that is the mode we use to read the result.
; This only keeps the high byte of the result, effectively dividing it by 255
C1021C JSR $00A7
C1021F LDA $AA
C10221 RTS
And a python equivalent:
def calc_distance(direction, movement_speed):
index = (direction << 2) & 0x3FF # ASL twice and mask to 10 bits
value = sine_table[index] # Get value from table
# Apply inversion for values in second half of the table (>= 0x200)
if index >= 0x200:
value *= -1
# Scale by movement speed and only keep high byte
result = (value * movement_speed) // 255
return result
InitializeMovement
This gives us the building blocks needed to intialize movement.
$DirTemp is $DB, it is populated either by the CalculateDirection function or if the direction has already been calculated it’s read from $9786 and stored in $DB. $EnemyIndex is $9C
initialize_movement:
C1379A LDX $EnemyIndex ; Load the current enemy index
C1379C STZ $A5FD,X ; Zero out something enemy related
C1379F LDA $9791,X ; Load indicator of whether a direction has been set
C137A2 BEQ $C137AB ; If a direction is set...
C137A4 LDA $9786,X ; Load direction
C137A7 STA $DirTemp ; Store it to temp
C137A9 BRA $C137CC ; Unconditional jump
C137AB: ; Else
; Calculate a new direction
C137AB LDA $1D0F,X ; Load enemy X position
C137AE STA $D3 ; Store it to a temp variable
C137B0 STA $98AF,X ; Store it to a the target X
C137B3 LDA $1D26,X ; Do the same thing with Y
C137B6 STA $D4
C137B8 STA $98B7,X
C137BB LDA $9752,X ; Load target PC
C137BE TAY
C137BF LDA $1D0C,Y ; Load target PC X
C137C2 STA $D5 ; Store it to temp
C137C4 LDA $1D23,Y ; Load target PC Y
C137C7 STA $D6 ; store it to temp
C137C9 JSR $0222 ; CalculateDirection and store in $DirTemp
C137CC:
C137CC LDA #$08 ; This 8 matches the number of movement steps used in ApplyMovement
C137CE STA $AE
C137D0 LDX $EnemyIndex ; Load enemy index into X
C137D2 LDA $DirTemp ; Load and store the direction to the enemies
C137D4 STA $9786,X ; direction address
C137D7 JSR $01F9 ; CalculateDistance for X
C137DA STA $8C ; Store it to $8C
C137DC CLC
C137DD LDA $DirTemp ; Reload direction again
C137DF ADC #$40 ; Add 0x40
C137E1 JSR $01F9 ; CalculateDistance for Y
C137E4 STA $8E ; Store to $8E
C137E6 LDA $DirTemp
C137E8 TAX
C137E9 LDA $C0F700,X ; This table maps angles to sprite facing directions
C137ED LDX $EnemyIndex
C137EF STA $96DE,X ; Store sprite direction for this enemy
C137F2 LDA $9752,X ; Load current target for this enemy
C137F5 TAY
; Make a copy of target's X and Y
C137F6 LDA $1D0C,Y
C137F9 STA $A039,Y
C137FC LDA $1D23,Y
C137FF STA $A050,Y
; Make a copy of the enemie's X and Y
C13802 TXA
C13803 CLC
C13804 ADC #$03
C13806 TAX
C13807 LDA $1D0C,X
C1380A STA $A039,X
C1380D LDA $1D23,X
C13810 STA $A050,X
; Determine which distance to use when determining if we're close enough
C13813 LDA $A2B5 ; This is a parameter in the Enemy AI script
C13816 DEC ; Decrement it
C13817 BNE $C1381E ; If $A2B5 was 1 then
; This function checks to see if the distance between the enemy and
; the target is < 0x400.
C13819 JSR $2AEF
C1381C BRA MoveTowardsTargetIfNotInRange
C1381E DEC ; Decrement AI value again
C1381F BNE $C13826 ; If $A2B5 was 2 then
; This path hasn't been analyzed yet, but based on the branch
; to MoveTowardsTargetIfNotInRange it is probably setting a different
; distance threshold
C13821 JSR $2AFB
C13824 BRA MoveTowardsTargetIfNotInRange
; If $A2B5 was 3 do something similar to if it was 2
C13826 DEC
C13827 BNE $C13831
C13829 JSR $2B07
; The implementation of MoveTowardsTargetIfNotInRange follows this line
; so the BRA instruction isn't needed for this scenario
At this point we’ve calculated a direction, how far to move, and whether we’re “in range”. Then all paths lead to MoveTowardsTargetIfNotInRange
Move towards the target for attack
CalcBoundaries
This function calculates the collision boundaries of a sprite and stores them for collision detection.
CalcBoundaries:
; Calculate the left boundary and store it to $975A
C1285A SEC ; Set carry flag (for subtraction)
C1285B LDA $A039,X ; Load X position
C1285E BMI $C12868 ; Branch if negative (left side of screen)
C12860 SBC $971E,X ; Subtract width value
C12863 BPL $C1286B ; If result is positive, store it
C12865 TDC ; Otherwise, set A to 0
C12866 BRA $C1286B ; Branch to store
C12868: ; If X was negative:
C12868 SBC $971E,X ; Still subtract width
C1286B: ; Store result
C1286B STA $975A,X ; Store left boundary
; Calculate the right boundary and store it to $9770
C1286E CLC ; Clear carry flag (for addition)
C1286F LDA $A039,X ; Load X position again
C12872 BPL $C1287D ; Branch if positive (right side of screen)
C12874 ADC $971E,X ; Add width
C12877 BMI $C12880 ; If result still negative, store it
C12879 LDA #$FF ; Otherwise, cap at FF (255)
C1287B BRA $C12880 ; Branch to store
C1287D: ; If X was positive:
C1287D ADC $971E,X ; Add width
C12880: ; Store result
C12880 STA $9770,X ; Store right boundary
; Calculate upper boundary and store it to $9765
C12883 SEC ; Set carry flag (for subtraction)
C12884 LDA $A050,X ; Load Y position
C12887 BMI $C12891 ; Branch if negative (top of screen)
C12889 SBC $9729,X ; Subtract height value
C1288C BPL $C12894 ; If result is positive, store it
C1288E TDC ; Otherwise, set A to 0
C1288F BRA $C12894 ; Branch to store
C12891: ; If Y was negative:
C12891 SBC $9729,X ; Still subtract height
C12894: ; Store result
C12894 STA $9765,X ; Store top boundary
; Calculate lower bounadry and store it to $977B
; This includes handling a special case where bit 2 is set in $2989
; When this is the case the Y position is used as the bottom boundary
; Otherwise 8 pixels are added to the Y position
C12897 LDA $2989 ; Load battle settings byte 1
C1289A AND #$04 ; Check bit 2
C1289C BEQ $C128A6 ; If not set, branch to normal calculation
C1289E LDA $A050,X ; Load Y position
C128A1 STA $977B,X ; Store directly as bottom boundary
C128A4 BRA $C128AF ; Branch to RTS
C128A6: ; Normal calculation:
C128A6 CLC ; Clear carry flag (for addition)
C128A7 LDA $A050,X ; Load Y position
C128AA ADC #$08 ; Add 8 pixels
C128AC STA $977B,X ; Store as bottom boundary
C128AF RTS ; Return from subroutine
CheckBoundaryCollision
This function takes the previously calculated boundaries for the current entity and then compares it’s boundary with the boundaries of all other entities to check for collisions. If the entity collides with another enemy then 0x81 is returned, if it collides with a PC then 0x80 is returned. If no collision occurs then 0 is returned.
C128B0 TDC
C128B1 TAY
C128B2 LDX $80 ; Load current entity index into X
$C128B4: ; Main loop start
C128B4 CPY $80 ; Compare Y with current entity index
C128B6 BEQ no_collision ; Skip if comparing entity with itself
C128B8 LDA $96F5,Y ; Check if entity Y is active
C128BB BEQ no_collision ; Skip if entity Y is inactive
C128BD LDA $9FF7,Y ; Check another flag
C128C0 BMI no_collision ; Skip if bit 7 is set
C128C2 LDA $A5CD,Y ; Check another flag
C128C5 BMI no_collision ; Skip if bit 7 is set
; Check left boundary
C128C7 LDA $975A,X ; Load left bound of entity X
C128CA CMP $975A,Y ; Compare with left bound of entity Y
C128CD BCC $C128F1 ; If X's left < Y's left, branch to check other case
C128CF CMP $9770,Y ; Compare X's left with Y's right
C128D2 BEQ $C128D6 ; If equal, continue checks
C128D4 BCS no_collision ; If X's left >= Y's right, no collision, continue loop
$C128D6: ; X's left bound is in Y's horizontal range
C128D6 LDA $9765,X ; Load top bound of entity X
C128D9 CMP $9765,Y ; Compare with top bound of entity Y
C128DC BCC $C128E7 ; If X's top < Y's top, branch to check other case
C128DE CMP $977B,Y ; Compare X's top with Y's bottom
C128E1 BEQ collision_detected ; If equal, collision detected
C128E3 BCC collision_detected ; If X's top < Y's bottom, collision detected
C128E5 BCS no_collision ; If X's top >= Y's bottom, no collision
C128E7:
C128E7 LDA $977B,X
C128EA CMP $9765,Y
C128ED BCC no_collision
C128EF BCS collision_detected
C128F1:
C128F1 LDA $9770,X
C128F4 CMP $975A,Y
C128F7 BCC no_collision
C128F9 LDA $9765,X
C128FC CMP $9765,Y
C128FF BCC $C1290A
C12901 CMP $977B,Y
C12904 BEQ collision_detected
C12906 BCC collision_detected
C12908 BCS no_collision
C1290A:
C1290A LDA $977B,X
C1290D CMP $9765,Y
C12910 BCC no_collision
C12912 BCS collision_detected
no_collision: ; No collision with entity Y
C12914 INY ; Increment Y to check next entity
C12915 CPY #$000B ; Compare Y with max entities (11)
C12918 BNE $C128B4 ; If not done, continue loop
C1291A TDC ; If no collisions found, clear A (return 0)
C1291B BRA $return ; Branch to return
collision_detected: ; Collision detected!
C1291D LDA #$80 ; Load A with $80 (bit 7 set)
C1291F CPY #$0003 ; Compare Y with 3
C12922 BCC $return ; If Y < 3 (player character), return $80
C12924 INC ; Otherwise (enemy), increment A to $81
return:
C12925 RTS
MoveTowardsTargetIfNotInRangec
MoveTowardsTargetIfNotInRange:
C1382C LDA $9872
C1382F BMI target_not_in_range
; If the target is already in range some values are updated and the target's
; X/Y coordinates are stored at $9831,X and $9839,X where X is the attacking
; entity ID.
; Right now the focus is on movement and this is the case where movement is
; finished, so this may be investigated further when researching attacks themselves.
C13831 LDX $EnemyIndex ; Load current enemy index
C13833 INC $9829,X ; Increment some counter/state variable
C13836 LDA $5E0D,X ; Load a value (believed to be an AI script value)
C13839 STA $5E1D,X ; Store it elsewhere
C1383C LDA $9752,X ; Load current enemy's target entity ID
C1383F TAY ; Transfer to Y for indexing
C13840 LDA $1D0C,Y ; Load target's X coordinate
C13843 STA $9831,X ; Store as target X coordinate
C13846 LDA $1D23,Y ; Load target's Y coordinate
C13849 STA $9839,X ; Store as target Y coordinate
C1384C STZ $98A7,X ; Clear movement flag, we're done moving
C1384F BRA exit ; Branch to exit
target_not_in_range:
C13851 LDX $EnemyIndex ; Load current enemy index
C13853 CLC ; Clear carry for addition
C13854 LDA $1D26,X ; Load Y coordinate
C13857 ADC $8C ; Add movement delta Y
C13859 STA $A053,X ; Store new Y position
C1385C CLC ; Clear carry for addition
C1385D LDA $1D0F,X ; Load X coordinate
C13860 ADC $8E ; Add movement delta X
C13862 STA $A03C,X ; Store new X position
; Here the entity ID is incremented 3 times. This is because the functions that
; are about to be called are working with the Entity index rather than the Enemy index.
; That is, PCs are Entity 0-2, Enemies are Entity 3-B, but we've been working with 0 indexed
; Enemy IDs (0-8).
C13865 INX
C13866 INX
C13867 INX
C13868 STX $80
C1386A JSR $CalcBoundaries ; Populate boundary positions for this entity
C1386D STZ $9875
C13870 LDA $A2B5 ; This is one of the AI Script parameters
C13873 CMP #$03 ; If it's not 3 then keep the value as is
C13875 BNE $not_three ; and keep $9875 as 0
C13877 LDA #$01 ; Otherwise, load 1 into A and $9875
C13879 STA $9875
not_three:
C1387C JSR $CheckEnvironmentCollision
C1387F BMI $collision_true
; If the AI parameter is not 3 then we also perform entity collision detection.
; Otherwise we skip that check.
C13881 LDA $A2B5 ; Load AI parameter again
C13884 CMP #$03 ; Compare with 3
C13886 BEQ $movement_success ; If 3, branch to movement success
C13888 JSR $28B0 ; Call entity collision detection routine
C1388B BPL $C138AF ; If no collision with entities, branch to movement success
collision_true:
; If a collision is detected the enemy's direction is modified, the movement flag is cleared
; and they will try to move in this new direction next time.
C1388D LDX $EnemyIndex
C1388F CLC
C13890 LDA $DirTemp
C13892 ADC #$40 ; Add 90 degrees to the direction
; These shifts are taking the new direction and simplifying it down to a single
; cardinal direction. So for example, if the enemy is trying to move 45 degrees
; down and to the right 90 degrees would be added, moving it down and to the left
; but then that is essentially rounded down to just moving down.
; If collision detection fails again for the same entity it would then move left,
; then up, then right on subsequent failures.
C13894 LSR
C13895 LSR
C13896 LSR
C13897 LSR
C13898 LSR
C13899 LSR
C1389A ASL
C1389B ASL
C1389C ASL
C1389D ASL
C1389E ASL
C1389F ASL
C138A0 STA $DirTemp
C138A2 LDA $DirTemp
C138A4 STA $9786,X ; Store the new direction
C138A7 INC $9791,X ; Increment collision counter
C138AA STZ $98A7,X ; Zero out movement flag so that ApplyMovement skips this entity
C138AD BRA $exit
movement_success:
C138AF LDX $EnemyIndex
C138B1 STZ $9791,X ; Clear collision counter
C138B4 LDA #$01
C138B6 STA $98A7,X ; Set enemy ready to move in ApplyMovement
exit:
; Increment X again to get the Entity ID rather than Enemy ID, store it to $80, and return
C138B9 INX
C138BA INX
C138BB INX
C138BC STX $80
C138BE RTS
At this point, the enemy is either ready to move towards the target PC or their movement would cause a collision and they are ready to be recalculated next time through. However, since the movement flag was cleared out that enemy will need to wait out it’s movement timer before it can try again, so in the meantime it will stand still.
A Practical Example
During the first battle in Truce Canyon against the three Blue Imps the first entry in their AI script is to do a close range attack on the nearest PC (there’s only one PC so it’s always Crono)
After a few more frames Enemy 0’s movement timer is up so it calculates a direction. Enemy 0 is the top right Blue Imp in this fight. When the direction was initially calculated it was calculated as 0x55. However, a collision was detected (either the flowers or the rock, I’m not 100% sure at this point). Because of the collision the direction is recalculated as direction + 0x40 rounded down to the nearest 90 degree angle which is 0x80.
Wrapping my head around the angles here is tough, keep in mind that X values increase going left to right while Y values increase going top to bottom (so the top left of the screen is 0,0 rather then the bottom left as you would expect in a normal graph). Because of this and angle of 0x40 is straight down rather than straight up and 0xC0 is straight up. However, because X behaves like a normal graph 0x00 is right and 0x80 is left.
With this in mind, an angle of 0x55 is down and slightly left, which matches what we see in the image. Since there is a collision the angle is moved to be straight left instead.
This will all need to wait though because the collision causes the movement timer to reset, so it will be another 16 frames before enemy 0 tries to move again.
This frame is more straight forward, there are no obstacles for the next Blue Imp so it simply calculates the direction towards Crono and is marked ready to move because they did not have collisions.
While it was marked active, they do not move on this frame because of their move rate (discussed in ApplyMovement) only moves them every other frame.
On the next frame the final enemy sets it’s direction and Enemy 1’s (E1) movement timer decrements.
On the next frame E1’s movement timer has hit 0 and reset, so it moves. Meanwhile E2’s movement timer decrements.
E2 moves and E1’s movement timer decrements again.
Several frames later E1 is about to finish the movement.
The next frame E1 has finished and E2 is about to finish.
Both enemies have now finished moving. E0 is finally about to come out of the penalty box.
On the next frame E0 is finally marked as active.
Several frames later, E0 has finished moving.
From here the pattern continues, E0 hasn’t cleared the flowers/rock so it fails the collision check again and will be delayed another 16 frames. E1 and E2 will finish getting close enough to attack.
What’s next?
One thing that can be seen in the images from the example is that the movement timers are updating 2 times per ATB tick, but sometimes ApplyMovement is called 3 times. It’s not clear at this point what the timing is, hopefully it is something as simple as having a 3 update frame every X frames.
This analysis has only covered one movement scenario, the movement towards a PC prior to a close range attack. There are many other movement scenarios that also need to be analyzed.