Active Time Battle Code and Delay

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