Active Time Battle Code and Delay
Contents
General Information[edit]
Thanks to Pseudoarc and inuksuk.
Chrono Trigger[edit]
Initial Research[edit]
While playing Chrono Trigger, I noticed that ATB recharge time was unusual with dual and triple techs. You can see this in action at (not my video) https://www.twitch.tv/videos/937335977. Google search gave a few results with others noticing the same phenomenon but no description of what the exact penalty is.
The relevant code is in the subroutine beginning at $C1BD6F. A relevant code fragment is below: Code: [Select]
$C1/BE6D AD 8C B1 LDA $B18C [$7E:B18C] $C1/BE70 AA TAX $C1/BE71 BF DC 2B CC LDA $CC2BDC,x[$CC:2BE2] $C1/BE75 29 0F AND #$0F $C1/BE77 AA TAX $C1/BE78 B9 AB AF LDA $AFAB,y[$7E:AFB1] $C1/BE7B C9 FF CMP #$FF $C1/BE7D F0 25 BEQ $25 [$BEA4] $C1/BE7F 7B TDC $C1/BE80 86 28 STX $28 [$00:0028] $C1/BE82 B9 AB AF LDA $AFAB,y[$7E:AFB1] $C1/BE85 AA TAX $C1/BE86 86 2A STX $2A [$00:002A] $C1/BE88 20 0B C9 JSR $C90B [$C1:C90B] $C1/BE8B A6 2C LDX $2C [$00:002C] $C1/BE8D 86 28 STX $28 [$00:0028] $C1/BE8F A2 0A 00 LDX #$000A $C1/BE92 86 2A STX $2A [$00:002A] $C1/BE94 20 2A C9 JSR $C92A [$C1:C92A] $C1/BE97 A5 2C LDA $2C [$00:002C] $C1/BE99 18 CLC $C1/BE9A 79 AB AF ADC $AFAB,y $C1/BE9D 90 02 BCC $02 [$BEA1] $C1/BE9F A9 FF LDA #$FF $C1/BEA1 99 AB AF STA $AFAB,y $C1/BEA4 99 DD 99 STA $99DD,y $C1/BEA7 99 22 9F STA $9F22,y
At the start of this fragment, Y holds the index of the player character (0,1, or 2) whose ATB is being reset after a tech. The tech id is in $B18C. The tech id is used as an index into memory beginning at $CC2BDC. Only the four low order bits of this number are kept. Call this atb_pen.
At the point in the subroutine, the character's normal max_atb is sitting in $AFAB+Y. For the record, I chased this down, and max_atb=(1+battle_speed)(25-char_speed). This is the number of frames it takes for the atb to fill up. What happens next is that max_atb is rewritten with max_atb + (max_atb*atb_pen)/10. In other words, an increase of atb_pen*10 percent. The final value is stored back in $AFAB+y as well as a few other places.
The story gets a little bit more interesting. Here's a view of the range beginning at $CC2BDC. Code: [Select]
00 10 20 30 30 50 30 50 60 10 10 20 20 10 20 30 30 10 10 10 20 20 30 40 50 10 10 20 20 20 30 30 40 10 10 20 20 30 30 30 50 10 10 20 20 10 30 40 50 20 20 20 20 20 10 30 40 11 32 53 11 31 53 11 23 54 11 23 35 11 23 23 21 33 35 11 22 22 22 33 23 11 22 43 11 23 43 11 33 43 21 22 32 12 12 23 11 12 33 31 32 55 53 31 33 35 23 53 35 12 12 21 33 52 32 11 53 03 02 03 03 03 03 03 01 04 01 04 02 03 03 02 FF FF FF FF FF FF FF FF FF FF FF FF
These numbers are the atb_pen values we described above in tech_id order. The basic attack is first with a 00 penalty. Then come each character's single techs. These all have the form X0, which might seem weird because it was the *low* order bits that were used above. It turns out while the low order bits are used as the penalty for some, the high order bits are used for the others. From what I can gather, whoever is first in the performance group gets the high order bits. Second and third seem to get the lower order four bits.
Here's the corresponding code for first in the performance group. Code: [Select]
$C1/BDF1 AD 8C B1 LDA $B18C [$7E:B18C] $C1/BDF4 AA TAX $C1/BDF5 BF DC 2B CC LDA $CC2BDC,x[$CC:2BE2] $C1/BDF9 4A LSR A $C1/BDFA 4A LSR A $C1/BDFB 4A LSR A $C1/BDFC 4A LSR A $C1/BDFD 29 0F AND #$0F $C1/BDFF AA TAX $C1/BE00 B9 AB AF LDA $AFAB,y $C1/BE03 C9 FF CMP #$FF $C1/BE05 F0 23 BEQ $23 [$BE2A] $C1/BE07 7B TDC $C1/BE08 86 28 STX $28 [$00:0028] $C1/BE0A B9 AB AF LDA $AFAB,y $C1/BE0D AA TAX $C1/BE0E 86 2A STX $2A [$00:002A] $C1/BE10 20 0B C9 JSR $C90B [$C1:C90B] $C1/BE13 A6 2C LDX $2C [$00:002C] $C1/BE15 86 28 STX $28 [$00:0028] $C1/BE17 A2 0A 00 LDX #$000A $C1/BE1A 20 2A C9 JSR $C92A [$C1:C92A] $C1/BE1D A5 2C LDA $2C [$00:002C] $C1/BE1F 18 CLC $C1/BE20 79 AB AF ADC $AFAB,y[$7E:AFB1] $C1/BE23 90 02 BCC $02 [$BE27] $C1/BE25 A9 FF LDA #$FF $C1/BE27 99 AB AF STA $AFAB,y $C1/BE2A 99 22 9F STA $9F22,y
Looks good, right? The value is shifted right four times so we use the high bits instead of the low ones. One crucial difference is that the value 10 for atb_pen/10 is never actually stored. After the LDX #$000A there should be STX $28 to pass into the division subroutine. Because this is missing, the division errors out and returns 0. There is no resulting atb penalty.
This is not too hard to fix. Fixing it does introduce another bug though. Because the new atb is not stored in $99DD+y like in the first code fragment, the atb bar bugs out for the first few frames. Adding in a STA $99DD,y instruction after computation fixes this bug. Be warned that everyone will be a big sluggish if you fix this. Luminaire, for example, carries a hefty 60% max_atb penalty. If you don't want any atb penalties, you can just NOP out the STX at $C1BE92 (edit: safer to change to STX #$00).
While I hope this is of intrinsic interest, it may be of some pratical use for rom hackers. If you expand the tech list, you may find yourself going beyond the expected range into those FFs and load up crazy atb penalties for your double techs and triple techs. This also may give some design space for balancing out powerful techs with more wild atb penalties.
Enjoy!
This great write-up helped find where Haste and Slow get calculated, too. Double thanks! (That subroutine is at $01BD6F.)
The Battle Engine, Explained[edit]
Introduction
The following is a deep dive into Chrono Trigger’s ATB system. It gets into the nuts and bolts of how the battle engine is coded, so some familiarity with the Super Nintendo's 65816 assembly language is assumed. My intention with this post is mainly to record what I've learned about the battle engine, but I also intend it to serve as an aid to aspiring hackers learning assembly. I've done my best to leave helpful comments on the code.
The Battle Update Loop
The following is a breakdown of the main Battle Update Loop. In this post I'll be concentrating on the bolded sections, which control how status timers are tracked and how a status effect is executed when its corresponding status timer expires. I'll begin with the section that actually executes the status effects (the last bolded section from address C18313 to C18340), and then back up and describe how the setup for that status execution loop works.
One important thing to note is that, in addition to the normal status effects like Poison, each PC and enemy's actions in battle are also controlled by this system. From the battle engine's point of view, units' turns are just another status in battle with 1) a timer and 2) an effect that gets executed when that timer expires.
Table 2.1 The main update loop Address Range Description C18120-C1812C Start the loop! Increment the tickCounter, make a draw() call, and clear the damage register. C18130-C18193 Victory/loss conditions are checked. First check whether all PCs are dead, then whether all enemies are dead. Some enemies can be flagged so that they're ignored for the victory check. C18195-C181C8 Update active status effect bitflags including critical HP status for PCs. C181CA-C181E9 Handle the ATB Wait conditions. If in Wait mode and a menu is open, only perform previously queued PC actions and don't advance status timers. C181EC-C1821E Handle running from battle. C18221-C1822E Purpose unknown. If any bit of $AF25 is set, just perform a turn action rather than process the status timers. C18231-C1828E Decrement each active status timer. C18290-C1829F Update turn gauge values. C182A2-C18311 On odd tickCounters, look for status timers that expired during the decrement loop. If any expired, set a bit on the status indicator. C18313-C18340 On odd tickCounters, read bits on the status indicator. If any are set, execute that status action. C18342-C183AB Debug mode that kills all enemies. C183AE-C183B5 Jump to the start of the loop to start the next tick. C183B8-C1845E End the battle (if the loop was exited).
There are a few details I gloss over in this post, such as all the handling of pre-scripted actions within the update loop. These pre-scripted actions are used in the Attract Mode that plays during the title sequence. This guide is primarily concerned with the "happy path" through the update loop which runs hundreds of times during regular battles.
Status Effect Execution
The loop that executes status effects in battle is impressively compact. It simply looks at 13 bits, and if any are set, fires the action associated with that bit. There is a considerable amount of setup and supporting architecture for this compact loop, of course, but I find it remarkable how much is accomplished here with just a couple dozen bytes of assembly.
When we arrive here, the statusUpdateIndicator has between 0-13 bits set to indicate which status effects, if any, should be executed on this tick of the main update loop. It might look like 0000 1000 0000 0001, for example, which would indicate that some unit's Poison status will deal damage and that some unit is ready to take an action (not necessarily the same unit).
The currentStatusIndicator is initially set to 0001 0000 0000 0000, which corresponds to the Stop status. That indicator bit is shifted right on each loop and each set bit on the statusUpdateIndicator has its corresponding effect executed.
(A quick note on how to read these code blocks: I've labelled variables and constants in pascalCase and locations that get branched to in snake_case. When you debug on your own and look at the raw assembly code, you'll normally just see the address of a variable. So for example, "LDA.W $0027" is written here as "LDA.W tickCounter" to describe what that temporary variable is used for, and branch instructions read like "BEQ process_debug_flag" instead of "BEQ $2B" which normally just tell you how many bytes to skip over. The label for the starting location of a loop will start with "for" or "foreach".)
Block 3.1 The status effect execution loop
Label Instruction Address Comment
start_of_status_execution:
LDA.W tickCounter C18313
BIT.B #$01 C18316
BEQ process_debug_flag C18318 If the tickCounter is even, branch past this loop.
REP #$20 C1831A Do this stuff when tickCounter is odd.
LDA.W #$1000 C1831C
STA.W currentStatusIndicator C1831F Start with 0x1000 to test Stop.
TDC C18322
TAX C18323
foreach_status_execute_ready_status:
LDA.W currentStatusIndicator C18324 Start of the loop.
BIT.W statusUpdateIndicator C18327 Compare the current status bit to the update indicator.
BEQ increment_status C1832A If the current status is not set for update, branch to continue the loop.
PHX C1832C Do this stuff to execute the current status effect.
TXA C1832D X held the index of the current status (00-0C).
ASL A C1832E Double the status index to read the pointer table.
TAX C1832F (Each pointer is 2 bytes.)
TDC C18330
SEP #$20 C18331 The pointer table is at C1B92D.
JSR.W (battle_function_ptr_table,X) C18333 IMPORTANT! Actually execute the current status effect.
REP #$20 C18336
PLX C18338
increment_status:
LSR.W currentStatusIndicator C18339 Prepare for the next iteration of the loop.
INX C1833C
CPX.W #$000D C1833D Perform 13 loops.
BCC foreach_status_execute_ready_status C18340
[...]
process_debug_flag:
LDA.L isBattleDebugEnabled C18345 The status execution loop is complete. Now check for debug mode.
[...]
This loop works by reading each bit of the statusUpdateIndicator, which were previously set by testing each unit in battle for each status and flagging any expired timers. It's important to note here that any action performed by a unit in battle is simply one of the statuses tracked by the ATB system. Each unit's turn timer is tracked by the system the same way Poison or Haste statuses are tracked. The Poison status has a fairly simple action associated with it, while the turn action is complex, but at this level of the system they're treated the same. For most statuses, the "effect" is just the removal of a temporary status like Haste.
This is a list of each status effect tracked by the ATB system and its corresponding index. The battle engine is set up to track 13 different statuses on each unit in combat. Three status effects are unused, which is lucky for any enterprising hackers that may want to add a status effect to the game! The infrastructure is already there. In a future post I plan to go over the edits I made in order to add a Regen status to the game.
Table 3.1 Status indexes Index Status 00 Stop 01 Poison 02 Slow 03 Haste 04 2.5x Evade 05 Barrier 06 Unused 07 Unused 08 HP Down 09 MP Regen 0A Safe 0B Unused 0C Turn Action
Status 0C is a huge deal! It represents the unit taking an action in battle and controls all attacks, Techs, items, enemy actions, counterattacks, etc. The function that executes that effect is very complex.
Preparing the status UpdateIndicator
The status effect execution loop is quite compact, but it takes a fair amount of setup. I'll now jump a bit higher in the main battle update loop and examine the two loops that prepare everything for the execution loop. First, the timer for each active status on each unit is decremented. If a timer expires, then a bitflag is set. Then, for each status, if the "expired" bitflag is set the corresponding bit on the statusUpdateIndicator gets set.
Here's the decrement loop that loops through each status for each unit in battle and decrements the timer if the status is active:
Block 4.1 The status timer decrement loop
Label Instruction Address Comment
foreach_status_bitflags:
LDX.W statusBitflagIndex C1823C Load the current loop index. Ranges from 0 to $8F.
LDA.W currentStatusBitflags,X C1823F Load current status bitflags from $AFB6 to $B044. Stop, poison, slow, etc.
BEQ increment_battler_index C18242 If the status is not active, branch to continue the loop.
BMI increment_battler_index C18244 If the timer has already been marked expired, branch to continue the loop.
TDC C18246 Do this stuff if the status is active and the timer hasn't expired.
LDA.W battlerIndex C18247 Load the inner loop index.
TAX C1824A
LDA.W currentStatusBitflags,X C1824B This always loads the current unit's Stop bitflags.
BEQ battler_is_not_stopped C1824E If this unit isn't Stopped, branch to decrement the current status timer.
LDA.W currentStatusTimer,X C18250 Do this stuff if the unit has Stop active.
DEC A C18253
STA.W currentStatusTimer,X C18254 Decrement Stop's timer instead of the current status.
CMP.B #$00 C18257
BNE increment_battler_index C18259 If the timer isn't expired, branch to continue the loop.
BRA set_flag_timer_expired C1825B Else branch to set the "timer expired" bitflag.
battler_is_not_stopped:
LDX.W statusBitflagIndex C1825D
LDA.W currentStatusTimer,X C18260 Load the current status's timer.
CMP.B #$00 C18263
BEQ set_flag_timer_expired C18265 If the timer is zero, branch to set the "timer expired" bitflag.
DEC A C18267
STA.W currentStatusTimer,X C18268 Else decrement the current status's timer.
CMP.B #$00 C1826B
BNE increment_battler_index C1826D If the timer is still not zero, branch to continue the loop.
set_flag_timer_expired:
LDA.W currentStatusBitflags,X C1826F IMPORTANT! Set the "timer expired" flag for the current status.
ORA.B #$80 C18272
STA.W currentStatusBitflags,X C18274
increment_battler_index:
INC.W battlerIndex C18277 Increment the index used to test the current unit's Stop status.
LDA.W battlerIndex C1827A
CMP.B #$0B C1827D
BCC increment_bitflags_index C1827F
STZ.W battlerIndex C18281 If the battler index is >= 11, set it to zero again.
increment_bitflags_index:
LDX.W currentStatusBitflags C18284 Increment the loop index.
INX C18287
STX.W currentStatusBitflags C18288
CPX.W #$008F C1828B Do the loop $8F times. ($0D statuses * $0B units.)
BCC foreach_status_bitflags C1828E
[...]
The above code runs on every tick of the main update loop, as opposed to the execution loop and the status update loop which only run on odd ticks. The following is the status update loop which checks for the "timer expired" bitflags set during the decrement loop and prepares the statusUpdateIndicator for the execution loop. There's an outer loop for each status and an inner loop for each unit. The loop can run a maximum of $8F times, once for each unit and status.
Block 4.2 The status update indicator loop
Label Instruction Address Comment
LDA.W tickCounter C182A2
BIT.B #$01 C182A5
BEQ start_of_status_execution C182A7 If the tickCounter is even, branch past this loop.
TDC C182A9 Do this stuff when the tickCounter is odd.
TAX C182AA
STX.B currentStatusIndex C182AB
LDX.W #$1000 C182AD Start with the bit that represents Stop.
STX.W currentStatusIndicator C182B0
foreach_status_and_unit:
TDC C182B3 Start of the loop.
LDY.B currentStatusIndex C182B4 Load the current status being tested. Ranges from 0 - $0C.
LDA.W unitIndexes,Y C182B6 Load the current unit index. This is how many times the current status has been tested.
TAX C182B9
LDA.W battleIDsRandomizedOrder,X C182BA Load the current unit's battle ID.
REP #$20 C182BD
STA.B currentBattleID C182BF
LDA.B currentStatusIndex C182C1
ASL A C182C3
TAX C182C4 Double the status index to read from a pointer table.
LDA.L statusBitflagsPointers,X C182C5 Load the pointer to the current status's bitflags array.
CLC C182C9
ADC.B currentBattleID C182CA Add the current unit's battle ID to the pointer.
TAX C182CC
TDC C182CD
SEP #$20 C182CE
LDA.W $0000,X C182D0 Load the current unit's current status bitflags.
BIT.B #$80 C182D3 Bit $80 was set during the decrement loop if the current status timer expired.
BEQ current_status_timer_is_not_expired C182D5 If the "expired" bitflag wasn't set, branch to test the next unit.
REP #$20 C182D7 Do this stuff if the current status timer expired.
LDA.W statusUpdateIndicator C182D9
ORA.W currentStatusIndicator C182DC
STA.W statusUpdateIndicator C182DF IMPORTANT! Set the bit for the current status on the update indicator.
TDC C182E2
SEP #$20 C182E3
LDY.B currentStatusIndex C182E5
LDA.W unitIndexes,Y C182E7
INC A C182EA
STA.W unitIndexes,Y C182EB Also increment the unit index for the current status.
BRA increment_current_status C182EE Then continue the outer loop, testing for the next status. Any given status effect can only be executed once per update.
current_status_timer_is_not_expired:
LDY.B currentStatusIndex C182F0 Do this stuff if the current status should not be executed for the current unit.
LDA.W unitIndexes,Y C182F2
INC A C182F5
STA.W unitIndexes,Y C182F6 Test the next unit for the current status (inner loop).
CMP.B #$0B C182F9
BCC foreach_status_and_unit C182FB Do the inner loop 11 times.
TDC C182FD
STA.W unitIndexes,Y C182FE Zero the unit index for the current status if no unit is ready to execute the current status.
increment_current_status:
INC.B currentStatusIndex C18301 Test the next status.
LDY.B currentStatusIndex C18303
REP #$20 C18305
LSR.W currentStatusIndicator C18307 Shift the indicator bit.
TDC C1830A
SEP #$20 C1830B
LDA.B currentStatusIndex C1830D
CMP.B #$0D C1830F Do the outer loop 13 times.
BCC foreach_status_and_unit C18311
start_of_status_execution:
LDA.W tickCounter C18313 The update loop is complete, start the status execution section.
[...]
And now the preparations for the status effect execution loop have been made! The logical flow goes "Decrement active status timers" --> "Flag expired status timers" --> "Execute flagged statuses".
A Sample Status Effect Execution
What does it look like when a status effect is actually executed? For most statuses, the "effect" is just whether the status should be removed from the unit. For example, the Haste status effect uses a second timer in addition to the one used during the timer decrement loop. The effect execution decrements the second timer and resets the first timer. Once the second timer expires, Haste is also removed from the unit.
The actual effect that Haste has on units, reducing the time between that unit's actions, is applied elsewhere in the code (after that unit takes an action in battle and needs its turn timer reset). Here's the Haste effect called by the execution loop:
Block 5.1 The Haste status effect
Label Instruction Address Comment
fn_execute_haste_action:
TDC C189B9 Do this stuff if a unit's Haste timer reached zero.
LDA.W unitIndex C189BA Load the current unit index for Haste stored in the unitIndexes array.
BNE update_status C189BD This should always be nonzero if we arrived from the execution loop.
LDA.W totalUnitSlotsInThisBattle C189BF Fallback to prevent underflow.
update_status:
DEC A C189C2 The unit index was incremented during the update indicator loop. Reverse that and use as an index to get the unit's battle ID.
TAX C189C3
LDA.W battleIDsRandomizedOrder,X C189C4 Load current battle ID.
TAY C189C7
LDA.B #$01 C189C8
STA.W battlerHasHaste,Y C189CA Clear bit $80, the "timer expired" flag. Keep bit $01, the "status is active" flag.
LDA.W hasteTimer1Max,Y C189CD
STA.W hasteStatusTimer1,Y C189D0 Reset Haste's ATB timer.
REP #$20 C189D3
LDA.W statusUpdateIndicator C189D5
AND.W #$1DFF C189D8
STA.W statusUpdateIndicator C189DB Clear the bit associated with Haste on the update indicator.
TDC C189DE
SEP #$20 C189DF
LDA.W hasteStatusTimer2,Y C189E1
DEC A C189E4
STA.W hasteStatusTimer2,Y C189E5 Decrement Haste's second timer.
BNE return C189E8 If the second timer hasn't expired, return.
LDA.B #$0A C189EA Do this stuff to remove the status from the unit.
STA.W hasteStatusTimer2,Y C189EC Reset the second timer to 10.
TYA C189EF
REP #$20 C189F0
XBA C189F2
LSR A C189F3
TAX C189F4 Convert the unit's battle ID to the unit's battle data offset.
TDC C189F5
SEP #$20 C189F6
LDA.W battlerStatusByte2,X C189F8
AND.B #$7F C189FB
STA.W battlerStatusByte2,X C189FD Clear Haste status.
TDC C18A00
STA.W battlerHasHaste,Y C18A01 Clear Haste bitflags.
return:
RTS C18A04
After the Haste effect runs, the program returns to the status execution loop. Once all the bits on the statusUpdateIndicator have been checked, the program continues with the next part of the main update loop.
Conclusion
That's it for the core of the ATB engine! Every event in battle is controlled by this central loop that tracks the timers for each active status and fires an effect when the timer expires. Handling player input and controlling PC actions is beyond the scope of this post, though of course they are a key part of the battle system as a whole. In the future I may make another post that breaks down the "turn action" function which incorporates player input and menu context to execute attacks, Techs, items, counterattacks, and more. Until then, happy hacking!
From: Modification
