Sonic CD Quirks/Deconstruction

Discussion in 'Discussion & Q&A' started by Devon, Jul 11, 2022.

  1. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Y'know what? I think I can make a thread out of this. Let's start off with the following:

    • Files for the Sub CPU code for the ending FMVs have the wrong filenames (GOODEND.BIN is for the bad ending, BADEND.BIN is for the good ending, .MMD and .STM files are correct, though)
      Level 16x16 blocks are compressed in Nemesis, which is usually only meant for graphics compression, not metadata compression.
    • Level demo files are built from a slightly older version of the level codebase.
    • R11A uses direct addresses for player variables in LevelSizeLoad, and also takes out some leftover Sonic 1 code, while the other level files I've seen pass the player slot into a6 and retain said leftover Sonic 1 code.
    • FMV PCM streaming code has an issue where it'll not write the last 8 bytes in a packet, due to the very last PCM bank having a loop flag set, and them lazily not wanting to overwrite it, resulting in playback quality degradation. Their setup turned out to be unnecessary anyways, but they probably didn't know that, since PCM playback address wrapping back to the start of wave RAM when going past the end isn't officially documented.
    • Speaking of FMV PCM streaming, there's code for setting up a second channel. It's possible that stereo audio was planned, but scrapped. The second channel's registers are set to corrupt data and actually does sound, but its volume is set to 0, so nothing is actually played back from it.
    • Each music track gets their own Sub CPU command entry for playing it. Doubled actually. A set for normal playback, and another for the sound test.
    • Each level gets their own Sub CPU command for loading it. They all point to the same function, but the command ID is used for another table for loading in the appropriate data.
    • The "I'm outta here" and time warp timers are updated every V-BLANK, so even if the game is lagging, they update at a steady 50/60 Hz. This explains why the Taxman version appears to take longer to do a time warp sometimes, even though it really isn't.
    • The Gems Collection of the game has leftover symbol data that can give a glimpse into how files were structured. While levels did use some commonly shared code, they did copy and paste other files for more specific things like scrolling, object lists, data, etc. Even if there are some functions that should also be shared from one common file, they aren't, and this explains some of the weird minute differences between level files.
    • Title screen has 3 unused object functions that are broken.
      • The first sets a "bookmark" that the object manager will jump to in future calls for that object. There's another version that does that and exits the object, and this broken one is meant to set it and then return back to the object. It breaks because it overwrites a0, which was also used for the object slot pointer.
      • The second one is meant to update the object's address manually, but in the title screen, object addresses are stored as words (with the upper word being automatically set to 0x00FF, since it's all loaded in Genesis RAM), and in this function, it writes a longword.
      • The third is meant to delete an object, but it advances a0 as it erases data, so it desyncs the object manager loop.
    • SMPS-PCM issues that aren't really to do with Sonic CD to begin with:
      • The driver is based on the US BIOS' version, where they took out the rhythm channel in a half-assed manner. They didn't remove the slot in the variables region and they didn't take out the channel ID in the assignment array for song tracks. As a result, the proper slot and channel assignment for track 8 are unreferenced. T̶h̶i̶s̶ ̶a̶c̶t̶u̶a̶l̶l̶y̶ ̶c̶a̶u̶s̶e̶s̶ ̶a̶ ̶b̶u̶g̶ ̶w̶i̶t̶h̶ ̶t̶r̶a̶c̶k̶ ̶1̶ ̶a̶n̶d̶ ̶8̶,̶ ̶b̶e̶c̶a̶u̶s̶e̶ ̶t̶h̶e̶ ̶r̶h̶y̶t̶h̶m̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶w̶a̶s̶ ̶a̶s̶s̶i̶g̶n̶e̶d̶ ̶t̶o̶ ̶u̶s̶e̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶8̶,̶ ̶w̶i̶t̶h̶ ̶s̶o̶n̶g̶ ̶t̶r̶a̶c̶k̶ ̶7̶ ̶u̶s̶i̶n̶g̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶8̶ ̶(̶I̶'̶l̶l̶ ̶e̶x̶p̶l̶a̶i̶n̶ ̶w̶h̶y̶ ̶i̶n̶ ̶t̶h̶e̶ ̶n̶e̶x̶t̶ ̶p̶o̶i̶n̶t̶)̶ ̶w̶h̶e̶n̶e̶v̶e̶r̶ ̶r̶h̶y̶t̶h̶m̶ ̶w̶a̶s̶ ̶n̶o̶t̶ ̶b̶e̶i̶n̶g̶ ̶u̶s̶e̶d̶.̶ ̶H̶o̶w̶e̶v̶e̶r̶,̶ ̶s̶i̶n̶c̶e̶ ̶t̶h̶e̶y̶ ̶f̶o̶r̶g̶o̶t̶ ̶t̶o̶ ̶t̶o̶t̶a̶l̶l̶y̶ ̶r̶e̶m̶o̶v̶e̶ ̶t̶h̶e̶ ̶r̶h̶y̶t̶h̶m̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶d̶a̶t̶a̶,̶ ̶t̶r̶a̶c̶k̶ ̶1̶ ̶u̶s̶e̶s̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶8̶,̶ ̶w̶h̶i̶l̶e̶ ̶t̶r̶a̶c̶k̶ ̶8̶ ̶a̶l̶s̶o̶ ̶g̶e̶t̶s̶ ̶a̶s̶s̶i̶g̶n̶e̶d̶ ̶t̶h̶a̶t̶ ̶c̶h̶a̶n̶n̶e̶l̶.̶ (EDIT: Disregard, was incorrect)
      • SFX support is very lackluster. Channel overriding does not exist. What they did was allocate SFX to play on channel 7 and try not to have the music use that channel. O̶r̶i̶g̶i̶n̶a̶l̶l̶y̶,̶ ̶w̶i̶t̶h̶ ̶t̶h̶e̶ ̶r̶h̶y̶t̶h̶m̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶i̶n̶t̶a̶c̶t̶,̶ ̶t̶r̶a̶c̶k̶ ̶8̶ ̶w̶a̶s̶ ̶a̶s̶s̶i̶g̶n̶e̶d̶ ̶t̶o̶ ̶u̶s̶e̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶7̶ ̶(̶w̶h̶i̶c̶h̶ ̶i̶s̶ ̶w̶h̶y̶ ̶t̶r̶a̶c̶k̶ ̶7̶ ̶w̶a̶s̶ ̶a̶s̶s̶i̶g̶n̶e̶d̶ ̶t̶o̶ ̶u̶s̶e̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶8̶)̶,̶ ̶s̶o̶ ̶y̶o̶u̶'̶d̶ ̶h̶a̶v̶e̶ ̶t̶o̶ ̶b̶e̶ ̶c̶a̶r̶e̶f̶u̶l̶ ̶t̶o̶ ̶n̶o̶t̶ ̶l̶e̶t̶ ̶S̶F̶X̶ ̶a̶n̶d̶ ̶t̶r̶a̶c̶k̶ ̶8̶ ̶c̶l̶a̶s̶h̶.̶ ̶H̶o̶w̶e̶v̶e̶r̶,̶ ̶h̶e̶r̶e̶ ̶w̶i̶t̶h̶ ̶t̶h̶e̶ ̶r̶h̶y̶t̶h̶m̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶p̶a̶r̶t̶i̶a̶l̶l̶y̶ ̶r̶e̶m̶o̶v̶e̶d̶,̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶8̶ ̶n̶o̶ ̶l̶o̶n̶g̶e̶r̶ ̶c̶l̶a̶s̶h̶e̶s̶ ̶w̶i̶t̶h̶ ̶S̶F̶X̶,̶ ̶b̶u̶t̶ ̶l̶i̶k̶e̶ ̶I̶ ̶s̶a̶i̶d̶ ̶i̶n̶ ̶t̶h̶e̶ ̶o̶t̶h̶e̶r̶ ̶p̶o̶i̶n̶t̶,̶ ̶i̶t̶ ̶c̶l̶a̶s̶h̶e̶s̶ ̶w̶i̶t̶h̶ ̶c̶h̶a̶n̶n̶e̶l̶ ̶1̶,̶ ̶i̶n̶s̶t̶e̶a̶d̶,̶ ̶w̶h̶i̶c̶h̶ ̶i̶s̶ ̶a̶r̶g̶u̶a̶b̶l̶y̶ ̶w̶o̶r̶s̶e̶.̶ ̶I̶n̶ ̶t̶h̶e̶ ̶e̶n̶d̶,̶ ̶n̶o̶ ̶m̶a̶t̶t̶e̶r̶ ̶w̶h̶a̶t̶,̶ ̶t̶r̶a̶c̶k̶ ̶8̶ ̶i̶s̶ ̶j̶u̶s̶t̶ ̶n̶o̶t̶ ̶r̶e̶a̶l̶l̶y̶ ̶s̶a̶f̶e̶ ̶t̶o̶ ̶u̶s̶e̶ ̶i̶n̶ ̶g̶a̶m̶e̶p̶l̶a̶y̶.̶ (EDIT: Disregard, was incorrect)
      • The way that samples are being statically loaded into wave RAM if not set to be streamed can be problematic. A sample is defined in a table, and if you don't set it to be streamed, then you must define the location in wave RAM for it to be loaded into. The problem is that for samples that ARE being streamed, its location in wave RAM is automatically assigned based on the channel ID, and static samples do not take that into account.
      • For samples that are being streamed, the stream rate is fixed, in the sense that it fills up a designated wave RAM bank at $200 bytes per driver call, effectively making the maximum supported frequency around the native sample rate of the PCM chip. Playing samples faster than that will cause issues, as the streaming code won't be able to catch up in time.
    • In Wacky Workbench, they used a different codebase for the Sonic object. Most of the changes include handling the stage gimmicks and removing the extended camera. However, the spindash charge speed is also 50 instead of the standard 75. Right now, I have no clue why this is, though I do feel it might have to do with the disabled extended camera. It doesn't cause the charge to require a longer time, since that's based on a timer, and not the charge speed, though.
     
    Last edited: May 29, 2023
  2. ValleyBell

    ValleyBell Well-Known Member Member

    Joined:
    Dec 23, 2011
    Messages:
    166
    I didn't know that SFX in SMPS-PCM were that broken. That's interesting!
    You looked a lot more into SMPS-PCM than I did.

    The only trivia I have is this: The SMPS-PCM driver in SNCBNKB1.BIN matches the SMPS-PCM driver from SegaCD US BIOS v1.0 exactly on instruction level. (There are a few minor differences with pointers referencing the data section.)
    The SMPS-PCM drivers used by the actual levels had a few minor changes. I forgot what changed exactly.

    I also have a small clarification for sample streaming: The stream rate is not strictly "fixed", but adaptive with an upper limit of $200 bytes per channel and frame.
    That allows only for samples rates of 25.6 KHz (PAL, 512*50 bytes/second) or 30.7 KHz (NTSC, 512*60 bytes/second) of the 32 KHz that the chip actually can play.
    (Feel free to correct me, if I got the numbers wrong. It has been a while since I looked into SMPS-PCM.)
     
    Clownacy likes this.
  3. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    I guess it depends on how you look at it, but a slight correction I will make is that it actually streams $200 bytes every call to the driver until the designated half of the wave bank (sample streaming does double buffering) is filled up. It's been a little while since I've messed with it myself. Regardless, the number of bytes it will copy is fixed at $200, but it is timed with the sample playback position on when to fill up a bank. High frequency sample playback is still not ideal, though.
    Code:
        lea       PCMADDR-1,a0                  ; Get current sample playback position
        moveq     #0,d0
        moveq     #0,d1
        move.b    ptrkChannel(a3),d1
        lsl.w     #2,d1
        move.l    (a0,d1.w),d0
        move.l    d0,d1
        lsl.w     #8,d0
        swap      d1
        move.b    d1,d0
    
        move.w    ptrkPrevSampPos(a3),d1        ; Has it looped back to the start of sample RAM?
        move.w    d0,ptrkPrevSampPos(a3)
        cmp.w     d1,d0
        bcc.s     .CheckNewBlock                ; If not, branch
        subi.w    #$1E00,ptrkSampRAMOff(a3)     ; If so, wrap back to start
    
    .CheckNewBlock:
        andi.w    #$1FFF,d0                     ; Is it time to stream a new block of sample data?
        addi.w    #$1000,d0
        move.w    ptrkSampRAMOff(a3),d1
        cmp.w     d1,d0
        bhi.s     StreamSample                  ; If so, branch
    Code:
    StreamSample:
        addi.w     #$200,ptrkSampRAMOff(a3)     ; Advance sample RAM offset
    
        move.l     ptrkSampRemain(a3),d6        ; Get number of bytes remaining in sample
        movea.l    ptrkSamplePtr(a3),a2         ; Get pointer to sample data
        movea.l    ptrkSampleRAM(a3),a0         ; Get pointer to sample RAM
    
        move.b     ptrkChannel(a3),d1           ; Get up sample RAM bank to access
        lsl.b      #1,d1
        add.b      ptrkSampleBank(a3),d1
        ori.b      #$80,d1
        move.b     d1,PCMCTRL-PCMREGS(a4)
    
        move.l     #$200,d0                     ; $200 bytes per block
        move.l     d0,d1
    
    .StreamLoop:
        cmp.l      d0,d6                        ; Is the remaining sample data less than the block size?
        bcc.s      .PrepareStream               ; If not, branch
        move.l     d6,d0                        ; If so, only stream what's remaining
    
    .PrepareStream:
        sub.l      d0,d6                        ; Subtract bytes to be streamed from remaining sample size
        sub.l      d0,d1                        ; Subtract bytes to be streamed from block size
        subq.l     #1,d0                        ; Subtract 1 for dbf
    
    .CopySampleData:
        move.b     (a2)+,(a0)+                  ; Copy sample data
        addq.w     #1,a0                        ; Skip over even addresses
        dbf        d0,.CopySampleData           ; Loop until sample data is copied
    
        tst.l      d1                           ; Have we reached the end of the sample before the block was filled up?
        beq.s      .BlockDone                   ; If not, branch
    
        moveq      #0,d0                        ; Loop sample
        move.l     ptrkSampleSize(a3),d0
        sub.l      ptrkSampleLoop(a3),d0
        suba.l     d0,a2
        add.l      d0,d6
        move.l     d1,d0
        bra.s      .StreamLoop                  ; Start streaming more data
    

    The driver is called every other INT3 interrupt, whose interval is set to 255, which makes that (255+1)*30.72 μs, or 7864.32 μs, or 7.86432 ms. Multiply that by 2 to take into account the driver call every other INT3, and that's 15.72864 ms. That's 512 bytes/15.72864 ms, or 32.55208333 bytes/ms, or ~32552 Hz, which is around (if not exactly) the native sample rate of the PCM chip. Playing a sample faster than that will cause issues with streaming not being able to catch up. If it's playing slower than that rate, then the driver will simply wait for it to catch up, like I mentioned before.

    Technically, yes, it is "adaptive" if you take into account the waiting for the playback to catch up if it's going slower than the stream, but in the end, it's still copying a fixed number of bytes at a generally fixed interval, which is what I meant in my post.
     
    Last edited: Jul 11, 2022
    Clownacy likes this.
  4. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Here's a fun little one in Wacky Workbench. So, in the prototype, the background was green, like this:

    [​IMG]

    Later on, they changed it to be more blue, like this:

    [​IMG]

    In this stage, there are sections where sections of electrical wiring or whatever it's supposed to be flashes to indicate that it's active. It also makes the background flash. When they changed the background colors, they did update the flashing color data, but they forgot to update the code that restores the background color after flashing. This is why one of the major background colors turns green, like this:

    [​IMG]

    From R61A:
    Code:
    ROM:0020CD46                 lea     (palette+$7A).w,a3
                                 ...
    ROM:0020CD4E                 move.w  #$680,d2

    In this code, that $680 is the prototype color value. They forgot to change it to $A60.
     
  5. SpeedyGonzales

    SpeedyGonzales Newcomer Trialist

    Joined:
    Jul 5, 2022
    Messages:
    6
    I have been disassembling the 510 prototype from time to time over the last few years in order to see how they did certain things like the freezer and grabbing some of the Sonic sprites.
    Since each level and act is in reality is their own game it is tedious work that you go tired of after a few levels/acts.
    Because of that the code got evolved over time but they did not bother going back and improve the levels and acts they already done at that point.
    So you can see that they either got a better optimized assembler or had a programmer go through the code as the game progressed.
    Though you ralakimus have come quite a bit further since you done the menu already I can say that the code gets quite weird sometimes specially when it comes to the boss acts like R33C. That act changed quite a bit from 510 to final release so it might be that the code got better after that.
    And out of topic I started disassembling Ristar further than it had been done before last week and that code is quite a bit different from the Sonic games so Sonic Team had come quite a long way since Sonic 1 that Sonic CD is based on.
     
  6. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    The existence of some prototype code and also the fact that in 510, the spindash charge speed was set to 50 everywhere tells me that they forgot to apply certain updates to Wacky Workbench's version of the Sonic object during development... whoops!
     
    Last edited: Jul 16, 2022
    Clownacy and JGamer2151 like this.
  7. SpeedyGonzales

    SpeedyGonzales Newcomer Trialist

    Joined:
    Jul 5, 2022
    Messages:
    6
    Yes most likely they used the old, if it works do not fix it.
     
  8. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    The problem is that there's additional little bugs and inconsistencies. Particularly with the charge up sound effect, chibi Sonic hitbox being larger when rolling than the final size, and the slower spindash charge speed. The reason it's an older version is because they made the copy early in development, and they forgot to apply some code updates, resulting in these issues.
     
    JGamer2151 and Clownacy like this.
  9. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, the way that the special stage's countdown timer is sped up when you are in the water is rather odd. Lemme explain.

    Here's the timer update function:
    [​IMG]

    Ignoring time attack mode, every 20 "frames" (the special stages in NTSC run at 20 FPS, so really it's 60 frames, but for simplicity's sake, I will refer to frames in terms of 20 FPS, and not 60), it counts down a second. After that, it checks a separate counter. That counter, if set to a nonzero value, forces the game to count down a second for every frame that the counter is active. Where is this counter set up at? The water splash object, and it's slightly tied to how it animates.

    [​IMG]

    The water splash runs in 2 stages. When you first enter the water, it makes a large splash and that animation lasts 14 frames. The object's timer is set accordingly so that it continues on after the animation is over, but you might also notice that "timerSpeedUp" is set to... 10 frames. This means that there's a small time frame where it's not speeding up the countdown. Pay attention to this clip:

    [​IMG]

    As you can see, it does exactly that. So, that's the first stage of the water splash animation. After that one it goes into a loop of small splashes.

    [​IMG]

    Every time the animation loops, "timerSpeedUp" gets set to 2 frames, so that's how it's slightly tied to the water splash animation. What's kinda interesting, though is that the small water splash animation lasts 3 frames, and yet the speed up counter is being set to 2 frames. It effectively countdowns 2 seconds every 3 frames.

    Back to the timer update function, I should also mention that the regular stage countdown still runs alongside the speed up counter, so in some instances, it could countdown 3 seconds in a 3 frame span.

    I also took a look at what the 2011 remake did, and it does actually attempt to recreate that weird jolt in the countdown speedup, as shown in the GIF above. What it does in that, though, is that it sets a timer for 40 frames (this time in terms of 60 FPS), and it forces a second to be counted down every other frame as long as that timer's value is greater than 20, or has been counted down to 0.

    [​IMG]
     
    Last edited: Aug 14, 2022
  10. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Gonna make a quick point in here to say that the time warp cutscene sequence doesn't actually play while the next time zone is loading; the next time zone loads AFTER the cutscene is done playing (as soon as it fades to white, not when the sound is done playing; Z80 runs independently. This also explains why it still takes some time to transition after). I imagine that the cutscene was still made to make it appear less awkward, maybe. It also explains why the CDDA variant of the warp sound was able to play in prototype builds, since the CD drive cannot read other data and stream CD audio at the same time.

    Here's the supporting code:
    [​IMG]

    "RunMMD" loads and executes a Main CPU program file. It doesn't exit out until said program is finished running.

    Finally, a quick visualization of the memory, and you can clearly see it only start to load the next time zone after the cutscene fades to white:
     
    Last edited: Dec 26, 2022
  11. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Came across this little visual bug while combing through the special stage code. So, when you turn in the special stages, Sonic's sprite tilts towards the direction he's turning, and it's gradual. Well, the developers intended to make the untilting gradual as well, but a couple of bad branches make it so that he snaps back to the normal sprite instantly.

    Bugged:
    [​IMG]

    Intended behavior:
    [​IMG]

    This is the code that's responsible:
    Code:
    .Untilt:
        ; BUG: This function is bugged. It intends to gradually untilt Sonic's sprite,
        ; but with the way the branches are set up, it makes it instant instead
        cmpi.b    #5,oPlayerTilt(a0)     ; Are we tilting left?
        bcs.s     .UntiltLeft            ; If so, branch
    
    .UntiltRight:
        subq.b    #1,oPlayerTilt(a0)     ; Untilt from the right
        cmpi.b    #5,oPlayerTilt(a0)     ; Are we in the center?
        bcc.s     .UntiltLeft            ; If not, branch
        move.b    #5,oPlayerTilt(a0)     ; Cap at center
        rts
    
    .UntiltLeft:
        addq.b    #1,oPlayerTilt(a0)     ; Untilt from the left
        cmpi.b    #5,oPlayerTilt(a0)     ; Are we in the center?
        bls.s     .UntiltLeft            ; If not, branch
        move.b    #5,oPlayerTilt(a0)     ; Cap at center
        rts
    

    As you can see, the branches for when Sonic hasn't untilted all the way back to the center go to .UntiltLeft. When untilting from the right, it then performs a check if Sonic is right of the center, and make him snap to the center in that case. When untilting from the left, it ends up in a loop until the tilt value is at the center. Changing both branches to go to an RTS instruction instead gets you the intended behavior.

    Another thing I noticed was that the water slows you down in time attack mode, and only in time attack mode:
    Code:
    ObjSonic_Water:
        tst.b    timeStopped                ; Is time stopped?
        bne.s    .End                       ; If so, branch
    
        move.b   #8,splashObject+oID        ; Spawn splash object
        btst     #1,specStageFlags.w        ; Are we in time attack mode?
        beq.s    .End                       ; If not, branch
        move.w   #$500,oPlayerTopSpeed(a0)  ; If so, slow Sonic down
    
    .End:
        rts

    Finally, there's this unreferenced mode for Sonic's object code that makes him sink down out of the special stage indefinitely.
    Code:
    ; -------------------------------------------------------------------------
    ; Unknown mode
    ; -------------------------------------------------------------------------
    
    ObjSonic_Unk:
        addq.w    #4,oSprY(a0)               ; Move sprite down
        cmpi.w    #320+128,oSprY(a0)         ; Has it moved offscreen?
        bcs.s     .End                       ; If not, branch
        move.b    #6,oRoutine(a0)            ; Advance routine
    
    .End:
        rts
    
    ; -------------------------------------------------------------------------
    
    ObjSonic_Unk2:
        rts

    [​IMG]
     
    Last edited: Aug 27, 2022
    Clownacy, ProjectFM and DeltaWooloo like this.
  12. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    This... is a new one to me. I don't think I've seen a button tap check for a basic menu ever be implemented like this until now.

    I found this in the Visual Mode menu code:
    Code:
    ROM:00FF20E8 loc_FF20E8:
    ROM:00FF20E8                 btst    #0,($A1201E).l
    ROM:00FF20F0                 bne.s   loc_FF210E
    ROM:00FF20F2                 btst    #0,($FFFFF39E).w
    ROM:00FF20F8                 beq.s   loc_FF2114
    ROM:00FF20FA                 bclr    #0,($FFFFF39E).w
    ROM:00FF2100                 subq.w  #1,($FFFFF382).w
    ROM:00FF2104                 bge.s   loc_FF2114
    ROM:00FF2106                 move.w  #4,($FFFFF382).w
    ROM:00FF210C                 bra.s   loc_FF2114
    ROM:00FF210E ; ---------------------------------------------------------------------------
    ROM:00FF210E
    ROM:00FF210E loc_FF210E:
    ROM:00FF210E                 bset    #0,($FFFFF39E).w
    ROM:00FF2114
    ROM:00FF2114 loc_FF2114:
    What it's doing here is that it checks if up is being held (yes, the game uses MCD communication registers here for the controller read buffer), and if so, it sets a flag. If it's not being held, it checks if the flag is set, and if so, it gets cleared, and moves the menu selection up. Effectively, it moves the menu selection on button release instead of tap. What's weird about this is that the controller reading function is just the standard Genesis controller reading function, which includes checking which buttons are being tapped instead of held. Yet, this menu ignores that and does... that, instead for the D-Pad. They do use the tapped bits for A, B, C, and start. Why? I honestly couldn't tell you. Maybe they were implementing some kind of other thing with the button presses? Probably not, but who knows...
     
    Last edited: Sep 18, 2022
    JGamer2151 and Clownacy like this.
  13. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Actually, the entirety of Visual Mode's menu is overengineered. It uses an object system for the menu options. Each option is their own object, and they have a sprite assigned to them. They also use an animation handler, even if that's not really utilized. Makes me wonder if they were planning something much more complex for this, or if they were just hacking shit together in a rush...

    Also, side note, it upsets me that not even NemDec is consistent between files. Sometimes, certain branches will use bsr.w, other times it's jsr (pc). Sometimes, NOPs are added in, sometimes they aren't present.

    From a level MMD:
    Code:
    .NormalMode:
            lsl.w   #2,d2                           ; Get number of 8-pixel rows in the uncompressed data
            movea.w d2,a5                           ; and store it in a5
            moveq   #8,d3                           ; 8 pixels in a pattern row
            moveq   #0,d2
            moveq   #0,d4
            bsr.w   NemDec_BuildCodeTable
            move.b  (a0)+,d5                        ; Get first word of compressed data
            asl.w   #8,d5
            move.b  (a0)+,d5
            move.w  #16,d6                          ; Set initial shift value
            bsr.s   NemDec_ProcessCompressedData
            movem.l (sp)+,d0-a1/a3-a5
            rts
    From the title screen:
    Code:
    .NormalMode:
            lsl.w   #2,d2                           ; Get number of 8-pixel rows in the uncompressed data
            movea.w d2,a5                           ; and store it in a5
            moveq   #8,d3                           ; 8 pixels in a pattern row
            moveq   #0,d2
            moveq   #0,d4
            jsr     NemDec_BuildCodeTable(pc)
            move.b  (a0)+,d5                        ; Get first word of compressed data
            asl.w   #8,d5
            move.b  (a0)+,d5
            move.w  #16,d6                          ; Set initial shift value
            bsr.s   NemDec_ProcessCompressedData
            movem.l (sp)+,d0-a1/a3-a5
            rts
    From the Visual Mode menu:
    Code:
    .NormalMode:
            lsl.w   #2,d2                           ; Get number of 8-pixel rows in the uncompressed data
            movea.w d2,a5                           ; and store it in a5
            moveq   #8,d3                           ; 8 pixels in a pattern row
            moveq   #0,d2
            moveq   #0,d4
            jsr     NemDec_BuildCodeTable(pc)
            move.b  (a0)+,d5                        ; Get first word of compressed data
            asl.w   #8,d5
            move.b  (a0)+,d5
            move.w  #16,d6                          ; Set initial shift value
            bsr.s   NemDec_ProcessCompressedData
            nop
            nop
            nop
            nop
            movem.l (sp)+,d0-a1/a3-a5
            rts
     
    JGamer2151, Clownacy and DeltaWooloo like this.
  14. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, small bug in the time warp cutscene, but very subtle. It allocates 28 object slots for the sparkles that trail behind Sonic. 3 of them are set to be rendered in front of Sonic, and the other 25 behind him, so the processing code for them is split into 2, so that Sonic can be sandwiched in between them. However, there's a bug in it. Instead of starting on the first sparkle slot that's for being rendered behind Sonic, it accidentally starts at the first sparkle slot that's for being rendered in front. This means that the front sparkles are updated and rendered TWICE in a frame, and the last 3 sparkle objects never get updated.

    Code:
            lea     sparkleObjsF.w,a0       ; Run sparkle objects in front of Sonic
            moveq   #SPARKLEOBJFCNT-1,d7
    
    .FrontSparkles:
            move.w  d7,-(sp)
            bsr.s   RunSparkleObject
            move.w  (sp)+,d7
            adda.w  #oSize,a0
            dbf     d7,.FrontSparkles
          
            lea     sonicObject.w,a0        ; Run Sonic object
            bsr.w   RunSonicObject
          
            lea     sonicTrailObj1.w,a0     ; Run Sonic's trailing sprite objects
            bsr.w   RunSonicTrailObj
            lea     sonicTrailObj2.w,a0
            bsr.w   RunSonicTrailObj
            lea     sonicTrailObj3.w,a0
            bsr.w   RunSonicTrailObj
          
            ; BUG: Should be sparkleObjsB. As a result of this, the sparkles
            ; in front of Sonic are run and drawn twice, while 3 sparkles meant to
            ; appear behind Sonic never show up.
            lea     sparkleObjsF.w,a0       ; Run sparkle objects behind Sonic
            moveq   #SPARKLEOBJBCNT-1,d7
    
    .BackSparkles:
            move.w  d7,-(sp)
            bsr.s   RunSparkleObject
            move.w  (sp)+,d7
            adda.w  #oSize,a0
            dbf     d7,.BackSparkles
    
    Bugged:
    [​IMG]

    Fixed:
    [​IMG]

    It's subtle, but more sparkles appear when fixed. You can see it better towards the end when they trail off after Sonic moves up.
     
    Last edited: Sep 19, 2022
  15. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, after looking at DA Garden's code and noticing that it appears to have been done by the same programmer, here's my theory on this:

    I think they programmed all of DA Garden without even knowing that those bits for checking if a button was just tapped and not held down even existed. They are mostly unused in DA Garden sans the start button check, and they do the same exact weird manual button release checks. Then, when it came time to do the Visual Mode menu, they used the same general code base, and did those weird checks on the D-Pad. Someone probably came along, or maybe they looked deeper into how the controller reading function worked, and it was shown that those tapped bits existed, was like "oooooh...", and then used that going forward.

    Could I be wrong? Probably. But, that seems like the most probable thing, in my opinion.
     
    Last edited: Sep 21, 2022
  16. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Okay, more DA Garden shenanigans. Let's start off with a slight correction: DA Garden does used the tapped bits once, just for checking if the start button was pressed. This detail will be important later.

    So, when DA Garden initializes, it renders the planet before it fades from black so that it doesn't appear out of nowhere. It does 2 renders into both buffers to keep things synchronized. The Sub CPU handles updating the planet's position, angle, and size, and it also checks if the start button gets pressed to exit out of DA Garden. The initial planet render routine, however, has a bug in its CPU communication loop:
    Code:
            bset    #6,GAMAINFLAG                   ; Mark as exiting
    
    .WaitSubCPU:
            btst    #6,GASUBFLAG                    ; Has the Sub CPU responded?
            beq.s   .WaitSubCPU                     ; If not, wait (BUG: This should be a "bne.s")
            bclr    #6,GAMAINFLAG                   ; Communication is done
    The way that it's supposed to go is that, when the Sub CPU detects that the start button has been pressed, it sets bit 6 in the sub flag. The Main CPU is to then respond to that by setting bit 6 in the main flag, and then the Sub CPU responds to that by clearing bit 6 in the sub flag. It keeps them both in sync. However, as you can see in the code, it uses the wrong branch instruction to check if the Sub CPU has responded to the Main CPU's acknowledgement. Once the Main CPU sets its bit 6, the Sub CPU pretty much clears its bit 6 at the same time (it's already in its side of the communication, because the Main CPU waits for the Sub CPU to finish its updates), and thus the loop will just last forever, effectively being a game crash.

    This sounds pretty bad, but what if I told you that they actually DID "fix" this? At around the very beginning of the DA Garden program is this line:
    Code:
            move.w    #$8000,ctrlData                 ; Force program to assume start button was being held
    What this does is force the program to assume that start was being held before it started. When it finishes the first render, it does VSync, which in turns causes controller input to be updated, as you can see in this code:
    Code:
            bsr.w   UpdateSubCPU                    ; Update Sub CPU
            bsr.w   WaitWordRAMAccess               ; Wait for Word RAM access
            bsr.w   GetPlanetImage                  ; Get planet image
            bsr.w   AnimateVolcano                  ; Animate volcano
            bsr.w   GiveWordRAMAccess               ; Give back Word RAM access
            bsr.w   VSync                           ; VSync
    
            addq.w  #1,vintRoutine.w                ; Set next V-INT routine
            bsr.w   VSync                           ; VSync
            btst    #6,GASUBFLAG                    ; Is the Sub CPU exiting?
            bne.s   .Exit                           ; If so, branch
    If the start button is found to be pressed, because of that added line, the program thinks that this is because the start button is still being held down, even if that's not really true, and thus doesn't set the tapped bit. Now, if you don't have it pressed, then it all gets reset back to 0, meaning that, yes, the tapped bit can be set after the second render... but, keyword: "after". Like I said, VSync is called after a render is finished, so even if you can get the tapped bit to be set in this sequence, the Sub CPU will not actually see that, because it will have already updated at this point. And, thus, because of all this, technically, this bug is "fixed"... well, more like avoided. Without that hotfix, you can get the game to crash by pressing start on the right frame before it fades from black.

    It might sound like it's a frame specific thing to pull off without the hotfix, but actually you'd just be able to hold down the start button to trigger the crash, because at the very beginning, the controller data buffer is cleared out. This means that when it checks for the start button, it'll think that it was just pressed, and thus trigger the Sub CPU to exit out, and thus cause the crash on initialization.

    And as a side note, the Visual Mode menu retains that hotfix, which I believe further shows that it was indeed built from DA Garden's code. Another quick side note: if what I said was true about the weird button checks, then I wonder if this bug was what caused them to find out that those tapped bits existed. (EDIT: This theory grows even more true. The 0.51 prototype uses the held bit for checking the start button, and the hotfix doesn't exist. This also means that if you can boot into DA Garden in that prototype, you can trigger the crash by merely holding down the start button when it starts. Not even Visual Mode uses the tapped bits, they used the held bits for the face buttons. The face button checks got converted to use the tapped bits after the "fix" was put into place.)

    What a doozy.

    ...man I could start a blog with all the weird stuff I come across as I disassemble the game, lmao

    >programmer didn't know that there are bits you can check for when a button is tapped and released
    >they go on to only use the regular held bits, and for tapping, they manually checked for button release
    >DA Garden has a bug in the initialization routine that causes a crash if start is pressed to exit immediately, due to a bad branch in the CPU communication protocol, causing the Main CPU to enter an infinite loop
    >can't figure out why it's happening
    >oh god oh fuck
    >bitch about it to another programmer
    >that other programmer points out that those tap/release bits existed
    >try that
    >doesn't work, because controller data is cleared on initialization, and the controller update will treat the start button being held as just tapped, and thus set the bit, and trigger the crash
    >fuck it, no start button 4 u on initialization
    >crash "fixed"
    >yay
    >use tap/release bits for the rest of the button checks that were implemented
    >...well at least for the basic button checks, too lazy to change the manual button release checks
     
    Last edited: Sep 21, 2022
  17. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    I made a Twitter account for this kind of stuff, so if ya want it on your feed, then here ya go.
     
    DeltaWooloo likes this.
  18. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, I took a look into why Sonic looks the other way when turning, and it might be a bug? Here's the animation table for when Sonic is running on the ground. The format is that each row represents the direction Sonic is tilted, and each entry in the row represents the speed.
    [​IMG]

    Here's animations 2 and 3 for example, the slowest animations for turning slightly left and right.
    [​IMG]

    The sprites used normally are facing right, but animation 2 flips them to face left instead (by setting a bit in the highest byte of a sprite pointer, ala the "|$1000000").

    Although, I cannot 100% say that it really is a bug. Maybe it's just a weird choice made by the devs, maybe it's an oversight. In the end, it's just some food for thought, I suppose.
     
  19. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, apparently, originally, the developers did not know that objects could just be flipped with a flag in a level's object position data, so some earlier made objects actually have dedicated subtype definitions and animations for flipped objects, and even copied and pasted routines for handling them!

    One such object was this spring board object:
    [​IMG]

    As shown here, there are dedicated routines for when it's flipped. The only difference between them and the "normal" routines is that it uses the flipped animations and hitboxes.
    Code:
    .Index:
        dc.w    ObjSpringBoard_Init-.Index
        dc.w    ObjSpringBoard_NormalMain-.Index
        dc.w    ObjSpringBoard_FlipMain-.Index
        dc.w    ObjSpringBoard_NormalMain2-.Index
        dc.w    ObjSpringBoard_FlipMain2-.Index
        dc.w    ObjSpringBoard_NormalFling-.Index
        dc.w    ObjSpringBoard_FlipFling-.Index

    Later on in development, they did seem to figure out that objects can just simply be flipped, but instead of cleaning up the code, they just added another check to see if the object is flipped using the flag, and if so, set it to use the flipped subtype. This check did not exist in v0.02.
    Code:
        move.b   #3,d0                ; Normal animation
        move.b   #2,d1                ; Normal routine
        tst.b    oSubtype(a0)         ; Are we flipped?
        bne.s    .Flip                ; If so, branch
        btst     #0,oSprFlags(a0)     ; Is our sprite flipped?
        beq.s    .SetRoutine          ; If not, branch
    
    .Flip:
        move.b   #4,d0                ; Flipped animation
        move.b   #4,d1                ; Flipped routine
        bclr     #0,oSprFlags(a0)     ; Clear horizontal flip flags
        bclr     #0,oFlags(a0)
    
    .SetRoutine:
        move.b   d0,oAnim(a0)          ; Set animation
        move.b   d1,oRoutine(a0)       ; Set routine

    Also, as it is documented, v0.02 has a functional player 2. Various objects were programmed to check collision with both the main player and player 2. One such object was this floating block:
    [​IMG]

    As stated above, it did just that in v0.02:
    Code:
    ObjFloatBlock_TopSolid:
        lea      objPlayerSlot.w,a1
        bsr.w    .Check
        lea      objPlayerSlot2.w,a1
    
    .Check:
        move.w   oX(a0),d3
        move.w   oY(a0),d4
        jmp      TopSolidObject

    However, when they decided that there was not going to be a player 2, they removed all the checks. In this particular object, they didn't exactly do a good job, as they only got rid of the line that sets which object to check:
    Code:
    ObjFloatBlock_TopSolid:
        lea      objPlayerSlot.w,a1
        bsr.w    .Check
    
    .Check:
        move.w   oX(a0),d3
        move.w   oY(a0),d4
        jmp      TopSolidObject

    As a result, in the final, this object checks collision with Sonic twice in 1 frame.
     
    Last edited: Nov 4, 2022
  20. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    So, I was looking at the SolidObject routine in Sonic CD, and noticed 1 thing: in the other games, you would manually calculate the collision width (preferably, the player's X radius + the object's X radius). Sonic CD does it automatically by taking the object's width setting, and then adding 10 (9 (Sonic's normal X radius) + 1 (offsets the object's width setting)).

    I also noticed that the object specific checks were also moved inside SolidObject as well, including the monitor. However, what I found out is that it only deactivates collision for monitors if you hit it from the top or bottom when rolling, BUT NOT THE SIDES. But, somehow the game still allows Sonic to roll into the monitors?

    This is because the code for checking if Sonic should destroy a monitor is handled first in the frame, inside Sonic's object code. So, when a collision occurs, the destruction would take place first before the solid collision detection would have a chance to take into effect.

    There's 1 small issue though: the destruction check's hitbox has Sonic's X radius set to 8, not 9. Note how I said that it uses 9 for Sonic's X radius for solid collision detection. This means, if you position yourself right, you can make it so that Sonic lands in that 1 pixel gap where the solid collision will be detected, but not the destruction.

    [​IMG]