[Sonic 2, GitHub] Unique Level Data Per Act and In 2P Mode

Discussion in 'Engineering & Reverse Engineering' started by Ralakimus, Jul 6, 2020.

  1. Ralakimus


    pretty much a dead account Tech Member
    In Sonic 2, most level elements are shared between acts, and in 2 player mode, most data is taken from 1 player mode (with Casino Night's object layouts being the only exception). This guide will show you how you can support unique level data per act AND also in 2 player mode.

    1. Expanding the pointer tables

    Let's start off with the tables that are already set up per act and add 2 player slots. Let's start off in Off_Objects.

    The first thing you should see is this:
    Code (Text):
    1. Off_Objects: zoneOrderedOffsetTable 2,2

    "zoneOrderedOffsetTable" is a macro that sets up a lookup table for level data, with the pointers to said data being relative to the start of the table. The first parameter indicates the number of bytes that an entry in the table takes up. The second one indicates how many entries there are per zone. This is the one we are interested in. The table is already set up to take 2 entries per zone. To make it so that we can add the 2 player entries, change that second "2" into a "4" (2 acts * 2 player modes).

    Then, go through the table, and for each entry, make a copy of it after it, like so:
    Code (Text):
    1.     zoneOffsetTableEntry.w  Objects_EHZ_1   ; 0  $00
    2.     zoneOffsetTableEntry.w  Objects_EHZ_1   ; 0  (2 Player)
    3.     zoneOffsetTableEntry.w  Objects_EHZ_2   ; 1
    4.     zoneOffsetTableEntry.w  Objects_EHZ_2   ; 1  (2 Player)
    5.     zoneOffsetTableEntry.w  Objects_Null    ; 2  $01
    6.     zoneOffsetTableEntry.w  Objects_Null    ; 2  (2 Player)
    7.     zoneOffsetTableEntry.w  Objects_Null    ; 3
    8.     zoneOffsetTableEntry.w  Objects_Null    ; 3  (2 Player)
    9.     ...

    When you get the Casino Night entries copied. Change the pointers in the 2 player entries to "Objects_CNZ1_2P" and "Objects_CNZ2_2P":
    Code (Text):
    1.     zoneOffsetTableEntry.w  Objects_CNZ_1    ; 24 $0C
    2.     zoneOffsetTableEntry.w  Objects_CNZ1_2P    ; 24 (2 Player)
    3.     zoneOffsetTableEntry.w  Objects_CNZ_2    ; 25
    4.     zoneOffsetTableEntry.w  Objects_CNZ2_2P    ; 25 (2 Player)

    Next up, let's go to Off_Level. Things should look pretty familiar. Apply the same change you did with "zoneOrderedOffsetTable" before in here, and apply the same copying logic as before:
    Code (Text):
    1. Off_Level: zoneOrderedOffsetTable 2,4
    2.     zoneOffsetTableEntry.w Level_EHZ1   ; 0
    3.     zoneOffsetTableEntry.w Level_EHZ1   ; 0 (2 Player)
    4.     zoneOffsetTableEntry.w Level_EHZ2   ; 1
    5.     zoneOffsetTableEntry.w Level_EHZ2   ; 1 (2 Player)
    6.     zoneOffsetTableEntry.w Level_EHZ1   ; 2
    7.     zoneOffsetTableEntry.w Level_EHZ1   ; 2 (2 Player)
    8.     zoneOffsetTableEntry.w Level_EHZ1   ; 3
    9.     zoneOffsetTableEntry.w Level_EHZ1   ; 3 (2 Player)
    10.     ...

    Then, go to Off_Rings. Same deal here.

    Then go to LevelSize. Things are slightly familiar. You should see "zoneOrderedTable 2,8" at the start of the tables. Same logic as it is with zoneOrderedOffsetTable, but the pointers aren't relative to the start of the table and are just direct pointers to the data in the ROM. The parameters are the same as before. The "2" meaning that each entry takes up 2 bytes, and the "8" meaning 8 entries per zone.

    First, change the "8" into a "16" (4 * 2 acts * 2 player modes) there, and then do the same copying bit as before.

    Then, go to StartLocations. Same logic as before in LevelSize, except you're changing "4" into "8" (2 * 2 acts * 2 players modes). in the "zoneOrderedTable" macro.

    And that does it for the tables that were already set up per act. What about the ones that are just per zone? Let's start off in LevelArtPointers. If you go up, you'll see a macro definition for "levartptrs", which help set up the pointers in the table. This macro is only set up to handle per zone entries, so we need to update this macro to handle both per act and per player mode entries.

    In the section under "; declare some global variables to be used by the levartptrs macro", add this:
    Code (Text):
    1. cur_zone_off := 0

    And then change the "levartptrs" macro to this:
    Code (Text):
    1. ; macro for declaring a "main level load block" (MLLB)
    2. levartptrs macro plc1,plc2,palette,art,map16x16,map128x128
    3.     !org LevelArtPointers+zone_id_{cur_zone_str}*(12*4)+cur_zone_off
    4.     dc.l (plc1<<24)|art
    5.     dc.l (plc2<<24)|map16x16
    6.     dc.l (palette<<24)|map128x128
    7. cur_zone_off := cur_zone_off+12
    8.     if cur_zone_off>=(12*4)
    9. cur_zone_off := 0
    10. cur_zone_id := cur_zone_id+1
    11. cur_zone_str := "\{cur_zone_id}"
    12.     endif
    13.     endm

    And now, within the actual table go through each entry and make 3 additional copies of each like so:
    Code (Text):
    1. LevelArtPointers:
    2.     levartptrs PLCID_Ehz1,     PLCID_Ehz2,      PalID_EHZ,  ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   0 ; EHZ1  ; EMERALD HILL ZONE ACT 1
    3.     levartptrs PLCID_Ehz1,     PLCID_Ehz2,      PalID_EHZ,  ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   0 ; EHZ1  ; EMERALD HILL ZONE ACT 1 (2 PLAYER)
    4.     levartptrs PLCID_Ehz1,     PLCID_Ehz2,      PalID_EHZ,  ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   0 ; EHZ2  ; EMERALD HILL ZONE ACT 2
    5.     levartptrs PLCID_Ehz1,     PLCID_Ehz2,      PalID_EHZ,  ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   0 ; EHZ2  ; EMERALD HILL ZONE ACT 2 (2 PLAYER)
    6.     levartptrs PLCID_Miles1up, PLCID_MilesLife, PalID_EHZ2, ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   1 ; LEV1 ; LEVEL 1 ACT 1 (UNUSED)
    7.     levartptrs PLCID_Miles1up, PLCID_MilesLife, PalID_EHZ2, ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   1 ; LEV1 ; LEVEL 1 ACT 1 (UNUSED, 2 PLAYER)
    8.     levartptrs PLCID_Miles1up, PLCID_MilesLife, PalID_EHZ2, ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   1 ; LEV1 ; LEVEL 1 ACT 2 (UNUSED)
    9.     levartptrs PLCID_Miles1up, PLCID_MilesLife, PalID_EHZ2, ArtKos_EHZ, BM16_EHZ, BM128_EHZ ;   1 ; LEV1 ; LEVEL 1 ACT 2 (UNUSED, 2 PLAYER)
    10.     ...

    Then, change the one bit that checks if the table is set up properly at the end into this:
    Code (Text):
    1.     if (cur_zone_off<>0)&&(MOMPASS=1)
    2.     message "Warning: Table LevelArtPointers's last zone entry set is not complete"
    3.     endif
    4.     if (cur_zone_id<>no_of_zones)&&(MOMPASS=1)
    5.     message "Warning: Table LevelArtPointers has \{cur_zone_id/1.0} entries, but it should have \{no_of_zones/1.0} entries"
    6.     endif
    7.     !org LevelArtPointers+cur_zone_id*(12*4)

    And finally, let's go into Off_ColP and Off_ColS. Change the "1" in the second paramter for "zoneOrderedTable" in both tables into "4" (again, 2 acts * 2 player modes), and then for each entry in both tables, make 3 copies like so:
    Code (Text):
    1.     zoneTableEntry.l ColP_EHZHTZ    ; 0 (Act 1)
    2.     zoneTableEntry.l ColP_EHZHTZ    ; 0 (Act 1, 2 Player)
    3.     zoneTableEntry.l ColP_EHZHTZ    ; 0 (Act 2)
    4.     zoneTableEntry.l ColP_EHZHTZ    ; 0 (Act 2, 2 Player)
    5.     zoneTableEntry.l ColP_Invalid    ; 1 (Act 1)
    6.     zoneTableEntry.l ColP_Invalid    ; 1 (Act 1, 2 Player)
    7.     zoneTableEntry.l ColP_Invalid    ; 1 (Act 2)
    8.     zoneTableEntry.l ColP_Invalid    ; 1 (Act 2, 2 Player)
    9.     ...

    Then, go to PalCycle, Animal_PLCTable, JmpTbl_DbgObjLists, SwScrl_Index, InitCam_Index, DynamicLevelEventIndex, and
    AnimPatMaps. Same kind of logic as in Off_ColP/Off_ColS. (With AnimPatMaps, change the Casino Night two player pointers to "APM_CNZ2P".)

    Now, let's go to PLC_DYNANM. Again, you should see "zoneOrderedOffsetTable". However, despite the 2nd parameter being a "2", it's actually not per act. Rather there are 2 different pointers per zone for handling animated tiles. So, change that "2" into an "8". (2 * 2 acts * 2 player modes). Then, make 3 copies of each zone entry like so:
    Code (Text):
    1.     zoneOffsetTableEntry.w Dynamic_Normal    ; $00 (Act 1)
    2.     zoneOffsetTableEntry.w Animated_EHZ
    3.     zoneOffsetTableEntry.w Dynamic_Normal    ; $00 (Act 1, 2 Player)
    4.     zoneOffsetTableEntry.w Animated_EHZ
    5.     zoneOffsetTableEntry.w Dynamic_Normal    ; $00 (Act 1)
    6.     zoneOffsetTableEntry.w Animated_EHZ
    7.     zoneOffsetTableEntry.w Dynamic_Normal    ; $00 (Act 2, 2 Player)
    8.     zoneOffsetTableEntry.w Animated_EHZ
    9.     ...
    Then go to Obj28_ZoneAnimals, same logic here as in PLC_DYNANM.

    And now all of the tables have been expanded to hold data per act and per player mode!

    2. Fixing the code to properly reference the expanded tables

    Of course, the code that reads from these tables still need to be updated to account for the expansions made. First let's go to Level. Go to the section that starts with a comment saying "; multiply d0 by 12, the size of a level art load block". The block of code there takes the current zone and multiplies it by 12 to get the offset in LevelArtPointers. Of course, we need to change this so that it takes both the zone AND act ID and also takes into account 2 player mode.

    Above the comment you will see "move.b (Current_Zone).w,d0". Change the "move.b" to "move.w" (and also "Current_Zone" into ("Current_ZoneAndAct") so that we get both the zone and act ID in d0. However, we will need to change it so that the calculation starts off with "((Zone ID * 2) + Act ID) * 2". As it is right now, it's more "(Zone ID * 256) + Act ID". So under the line we just changed, add this:
    Code (Text):
    1.     ror.b    #1,d0
    2.     lsr.w    #6,d0

    The "ror.b" line effectively shifts the act ID bit closer to the zone ID in d0, resulting in "((Zone ID * 2) + (Act ID)) * 128", and the "lsr.w" line divides the result by 64, getting the "((Zone ID * 2) + Act ID) * 2" result we need. Why do we multiply this by 2? Well, we still need to add in the bit for the 2 player mode! In Sonic 2, "Two_player_mode" is always set to either 0 or 1, so we can just add the value of "Two_player_mode" onto our calculation!

    In the end, our changes should look like this:
    Code (Text):
    1.     move.w    (Current_ZoneAndAct).w,d0
    2.     ror.b    #1,d0
    3.     lsr.w    #6,d0
    4.     add.w    (Two_player_mode).w,d0

    The end result of this should go like this:
    "zone 0, act 1, one player mode" -> d0 = 0
    "zone 0, act 1, two player mode" -> d0 = 1
    "zone 0, act 2, one player mode" -> d0 = 2
    "zone 0, act 2, two player mode" -> d0 = 3
    "zone 1, act 1, one player mode" -> d0 = 4

    Then from there, the code will multiply that by 12 to get the pointer to the zone data that is needed to be loaded. However, we aren't done messing around with LevelArtPointers. There are 2 more instances in which it is referenced. First, let's go to LoadZoneTiles. You should see a "move.b (Current_Zone).w,d0". Apply the same changes as before here. Then do it again in loadZoneBlockMaps.

    Now, we can finally move on. Let's go to LoadCollisionIndexes. You should see another "move.b (Current_Zone).w,d0". Apply the same change as before here as well.

    Then go to AniArt_Load. Same change. Same deal in PalCycle_Load, LoadPLC_AnimalExplosion, Obj34_LoadStandardWaterAndAnimalArt, Obj28_InitRandom (change the "d0"s into "d1"s in the change here), Debug_Init, Debug_Main, loc_C4D0, RunDynamicLevelEvents, and InitCameraValues (change the "d0"s into "d2"s in the change here).

    Let's go to loc_402D4. It's the same logic as before here, but there's already a two player mode check for Casino Night, like so:
    Code (Text):
    1.     tst.w    (Two_player_mode).w
    2.     beq.s    +
    3.     cmpi.b    #casino_night_zone,(Current_Zone).w
    4.     bne.s    +
    5.     lea    (APM_CNZ2P).l,a0
    6. +

    So just remove that and just go ahead and apply the same ol' change.

    Now, let's go to loadLevelLayout. Things should already look a little familiar. It already kinda has the changes we've been applying before. However, it doesn't do the 2 player mode bit addition, so add that in. Then add a "add.w d0,d0" after that to multiply the result by 2.

    Then, go to RingsManager_Setup. Same logic here as in loadLevelLayout.

    Finally, let's go to ObjectsManager_Init. You should see our "ror.w" and "lsr.w" friends here, but you may also notice that there's already a check for two player mode. However, it's only for Casino Night Zone, and it's a super hackish check. What we are gonna do is remove this section of code:
    Code (Text):
    1.     tst.w    (Two_player_mode).w    ; skip if not in 2-player vs mode
    2.     beq.s    +
    3.     cmpi.b    #casino_night_zone,(Current_Zone).w    ; skip if not Casino Night Zone
    4.     bne.s    +
    5.     lea    (Objects_CNZ1_2P).l,a0    ; CNZ 1 2-player object layout
    6.     tst.b    (Current_Act).w        ; skip if not past act 1
    7.     beq.s    +
    8.     lea    (Objects_CNZ2_2P).l,a0    ; CNZ 2 2-player object layout
    9. +
    And basically do the same changes from loadLevelLayout in here.

    Then go to LevelSizeLoad. Scroll down some and you'll see some familiar lines of code again. What we need to do here is change the "4" in "lsr.w #4,d0" into a "6", then add the line that adds the two player mode bit, and then add "lsl.w #3,d0" afterwards. It should look like this:
    Code (Text):
    1.     move.w    (Current_ZoneAndAct).w,d0
    2.     ror.b    #1,d0
    3.     lsr.w    #6,d0
    4.     add.w    (Two_player_mode).w,d0
    5.     lsl.w    #3,d0

    Then later after the LevelSize table, you should see some more familiar code. Same basic logic as before, but we're changing the "5" into a "6", and then adding "lsl.w #2,d0".

    3. Fixing errors

    There are a couple errors that should pop up after all of this is done. One such error should look like this:
    Code (Text):
    2. > > >s2.asm(line): error: addressing mode not allowed on 68000
    3. > > >     movea.l    Off_ColS(pc,d0.w),a0

    The problem here is that Off_ColS is now too far away to be referenced using this instruction. To fix it, just split up the instruction like this:
    Code (Text):
    1.     lea    Off_ColS(pc),a0
    2.     movea.l    (a0,d0.w),a0

    Another error you should get is this:
    Code (Text):
    1. > > >s2.asm(line) zoneOffsetTableEntry(1) zoneTableEntry(3): error: range overflow
    2. > > >                 dc.w Objects_CNZ1_2P-current_offset_table
    3. > > >s2.asm(line) zoneOffsetTableEntry(1) zoneTableEntry(3): error: range overflow
    4. > > >                 dc.w Objects_CNZ2_2P-current_offset_table

    The problem here is that Objects_CNZ1_2P and Objects_CNZ2_2P are placed by themselves way too far away from Off_Objects. Just move them over with the rest of the object data under the table, and that should be fixed.

    Any other errors should just be stuff like "jump distance too big errors", in which you should change "bsr.s" to "bsr.w", "bra.s" to "bra.w", "bsr.w" to "jsr", and "bra.w" to "jmp".

    4. Conclusion

    And that's it! You should now be able to have separate acts and player modes have unique level data!
    Last edited: Jul 7, 2020
  2. SyntaxTsu


    Worked well for me - got it to allow me to do this to $0801.