Chromatophore / HP48-Superchip

Modified HP-48 Superchip Binary to address undesirable quirks present in the platform.
Other
32 stars 3 forks source link

FX1E and VF #2

Open tobiasvl opened 4 years ago

tobiasvl commented 4 years ago

The Wikipedia article on CHIP-8 claims that FX1E should set VF to 1 if I overflows from 0x0FFF and 0 if it doesn't.

This clearly is not how FX1E behaves in CHIP-8 on the COSMAC VIP (see disassembly); it will let I go all the way up to 0xFFFF. However, I see that CHIP-48 actually does check for an overflow from 0x0FFF (although it doesn't seem to touch VF).

The article says it's an undocumented feature that is relied upon by the game Spacefight! 2091, which was made for SCHIP (not sure which version), so is it conceivable that this is another incompatibility?

metteo commented 4 years ago

I have read multiple COSMAC VIP manuals several times already and it seems to me that there were valid cases where I would point to addresses over 0xFFF.

One such case would be 0xFX29 which loads I with the address of the font. On VIP fonts were cleverly packed in the operating system starting at 0x8110 so I'm guessing that 0xDXYN was able to reach it.

I'm currently developing an emulator in Java and I made I a 12 bit register but I'm planning to change that to make it more compatible with VIP.

BTW. Wikipedia has some info but it may be inaccurate at times. I did an edit recently regarding the stack (VIP had 16 levels originally but the article mentioned 24, because 48 bytes could hold 24 2byte words)

tobiasvl commented 4 years ago

@metteo, thanks for the reply. I'm not sure if I'm misunderstanding you, or you're misunderstanding me, but I wrote above that I'm aware that I could point to addresses above 0x0FFF on the VIP. I'll rewrite it a bit (originally wrote it on mobile) so it might be clearer.

My question is specifically whether the instruction FX1E set VF depending on whether I "overflowed" 0x0FFF or not, like the Wikipedia article claims. Since that's clearly not the case for the COSMAC VIP interpreter (see disassembly of FX1E handler here), I'm wondering whether either CHIP-48, S-CHIP 1.0 or S-CHIP 1.1 exhibited such behavior.

Sorry if I was unclear. I have also edited the Wikipedia article several times recently under my old handle "Spug" ;)

metteo commented 4 years ago

Yep, I understood it and wanted to add some context (hopefully useful).

Chromatophore commented 4 years ago

Well, I can say that the Chip48 source and SC10 source do this (from sc10 source line 1186+)

ifx1e:  ; increment I by VX
    clr.a   c
    move.b  @d0,c
    move.a  r0,a    ; get old I
    add.x   c,a ; increment, modifying only 3 low nibbles
    retcs       ; it wrapped around #1000
    move.a  a,r0    ; save new I
    retclrc

And the sc1.1 disassembly suggests nothing has changed here (00A48 in sc10, 00B50 in schip).

Rather importantly, you'll note that to perform the overflow test, it uses the instruction retcs (return if the carry bit is set), which will be the case if it increments to 0x1000. However, returning from the instruction handler with the carry bit set causes the interpreter to immediately exit on all HP48 versions, note label retloc in both source codes:

    jump.a  a   ; jump to instruction routine

retloc:
    brcs    errexit ; if carry is set, an error has occurred

I tested and verified this behaviour with an hp48 emulator & binaries from this repository with this simple chip8 program:

: main
    i := 0xFF0
    v1 := 1
    loop
        i += v1
        v0 := key
    again

Which exits out after the appropriate number of button pushes, as opposed to any not-crashing behaviour. So uh, yeah, it would appear that anything that's depending on that working is gonna have a bad time - vF is the least of one's concerns.

This behaviour also exists in the save/load from memory instructions. I suppose it's a good thing the I pointer is not incremented correctly in C48, SC10 or SCHIP, as if so it would be impossible to read from 0xFFF as it would step I over the top and cause an exit.

So, uh, someone says Spacefight! 2091 relies upon this? Maybe it relies upon a different quirk and wires got crossed? Do we uh, have a known good copy of the code for that game? Oh, of course we do, good ol hpcalc.org.

The included readme gives a date of 16/9-92, and schip 1.1 was released mid 91 so we can assume it was intended to work with that, maybe? The game does appear to work only with 1.1 on the calculator, although, well, there appears to be a lot of problems with the game? eg shooting the leftmost enemy doesn't seem to work correctly. Attempting to run the game with SCHPC, my quirks-modified version, causes the game to never progress, so, it appears to rely on something. That's an hp48 object in that zip though so you'll want to drop the 48 50 48 50 34 38 2D 4D 2C 2A 50 70 01 from the start.

Perhaps some better investigation into this game is in order?

Chromatophore commented 4 years ago

Good news!

So, if one plays the original SpaceFight! 2091: http://johnearnest.github.io/Octo/index.html?key=fFpIZemw

It has a very obvious bug where if you shoot eg Enemy 2 and then shoot Enemy 1, Enemy 2 will be accidentally be redrawn, causing a graphic error of the playfield. Other things can litter the playfield and render levels broken/unwinnable, when the enemies are moving down etc.

This error occurs because, the collision handler operates as follows. When the player bullet or enemies have moved, we scan for collisions. This enemy scan loop is performed around 0x052A, but it only considers the result if v5 is set to 1 (the code pulls double duty to just draw the enemies). Effectively what happens is: 1) Remove all active enemies from play field 2) Enable v5 to activate collision monitoring, then, redraw all enemies 3) IF the Enemy is active THEN draw each enemy in sequence (retaining sprite draw's vF) 4) IF vF is set THEN simply draw the enemy again to remove it, and register the hit (score, ending with delete the bullet) 5) repeat from 3 for next enemy

Effectively it's designed so that the only thing the enemy being drawn in Step 3 should collide with is a bullet. However, the issue arises because, while it checks if an Enemy is already defeated in Step 3, the test performed in Step 4 still occurs regardless. To my eyes, nothing in the loop deliberately clears vF. So, as vF is always set when erasing the player's bullet, with a successful hit of Enemy N, vF remains set when we scan enemy N+1.

Thus, if Enemy N+1 is already defeated, Step 3 is passed over, but Step 4 still tries to 'undraw' the enemy, ultimately dumping it into the display buffer. Since the player's bullet was already consumed defeating Enemy N, the last draw will be the player's score being updated, which will leave vF empty, ending the chain.

Additionally, if any other thing in the code has set vF, then the first enemy can be impacted in the same way.

So, going back to the suggested quirk: The process of selecting which enemy is being considered involves I += v9, specifically the one at 0x0532. If this cleared vF, as the quirk suggests would be the case, then the aftermath of any previous hit would no longer be considered. To verify this, I jumped to the end of the program, cleared vF, and then jumped back, and the bug no longer occured.

So, the important component here is that this game does appear to expect something to clear vF. However, it would appear that no version of Superchip that I have makes any attempt at interacting with vF.

This game bug would not occur if I += vX specifically clears vF, rather than sets it on overflow. For some conjecture, I suppose if someone implemented a chip8 interpreter and considered vX and I to be registers, I could believe that they might accidentally link vY += vX and I += vX, and have vF set as would be appropriate in that case. But I can't see any evidence that superchip would do this.

It's possible that it expects some other command to clear vF, but, there's not a lot of choices.

I've attached an Octo cartridge of my reverse engineered SpaceFight with the two bugs I found fixed (the game infinite loops if you shoot the enemy the same frame it tries to shoot you) SpaceFight! 2091 octo+fix1

I feel this may be outside the scope of the HP48 superchip implementations, but, it does appear to have some truth to it, and have some relevance to CHIP-8 history.

Chromatophore commented 4 years ago

Here's Octo code describing the loop in question. The whole process by which collisions are detected is rather naive with many memory read/writes - all that should really have been needed was to see if the bullet draw hit and figure it out with a little math. With the code in this format, hopefully that will help us draw some conclusions on what's going on here.

: draw_enemy_sprites
    # This function performs a draw of all enemy sprites
    # if v5 is set, enemy collisions will be acknowledged
    # in function enemy_sprite_collision

    # Load the X Y positions from memory:
    i := memory_enemy_position_data
    load v1

    # Copy to v6/v7
    v7 := v1
    v6 := v0

    # And uh, move down by 9 pixels because that's the gap between the ui
    # and the enemies
    v7 += 9

    # Init v8. v8 is which of the 5 enemies we're considering
    v8 := 0

    # Load up what kind of enemy we're displaying in this level
    # Store which sprite we're using in v9:
    i := memory_enemy_sprite_strided
    load v0
    v9 := v0

    : draw_enemy_sprites_loop
    # 1) Load if the enemy is alive:
    i := memory_enemy_status
    i += v8
    load v0

    # Then address to the correct enemy sprite (v9 stores offset)
    i := sprite_enemies
    i += v9

    # If the enemy is alive:
    if v0 != 0 then
        sprite v6 v7 0 # Draw the enemy:

    # NB vF may be unintentionally set entering this line of code
    # Detect any collisions:
    if vF != 0 then
        enemy_sprite_collision

    # Advance through enemy sequence (X offset, Enemy index)
    v6 += 0x12
    v8 += 0x01

    # Iterate over all 5 enemies:
    if v8 != 0x05 then
        jump draw_enemy_sprites_loop
;
tobiasvl commented 4 years ago

Really interesting as always! Thank you for the detective work. I feel like we should be able to solve this ancient Wikipedia mystery once and for all, but it seems the answer doesn't lie on the HP48.

Perhaps David Winter's CHIP-8 interpreter does something different here, and that became the basis for this game's behavior? His interpreter seems similar to CHIP48 in many ways, but of course some bugs might have snuck in.

Chromatophore commented 4 years ago

Man, reading comprehension once again yields many important clues. For example, reading that spacefig.doc file again, which is included in the archive from the HP calc website:

ABOUT THE GAME

It was written because I wanted to test my newly written S-Chip assembler for
the Amiga. It only took me 2 days to make this game (I'm so proud of myself!).

So, most likely there is a quirk in the game author's own Amiga interpreter. It is not a quirk/feature of superchip per source code/disassembly, and it is not a quirk/feature of the VIP interpreter either, if you (@tobiasvl) are correct (sadly, http://laurencescotford.co.uk/ appears to be down?).

However, it could be uncertain if it is instruction FX1E (I += vX) setting vF, or if 7XNN (vX == NN) might be responsible for clearing vF. Either one of these being true would fix the game's issues, I believe. The draw position is always shifted down by 9px (v7 += 0x09) earlier in this subroutine, and the loop contains eg v8 += 1. Unless I am mistaken, either instruction would be a candidate.

I don't know if it's possible to be sure either way at this time, unless that Amiga interpreter is found. The wiki note appears to be unsourced, but, I'm not sure if that was always true. Wiki edit history is not my area of expertise, though.

If you are curious if the quirk could be inferred from other code in the game, the only other uses of vF are related to the player/enemy bullets. The collision check is correctly gated in both cases, so vF will always be as expected, and no conclusion can be drawn. Here's octo code describing those:

# vF use while servicing player bullet:
: move_player_bullet
    # Note: this all happens regardless of if the bullet exists or not.
    # Undraw the bullet:
    draw_bullet             # call 0x05E6
    i := memory_player_bullet_xy
    load v1 
    v1 += -2 

    # Check if it's hit the top of the screen (jumps out of sub & returns)
    if v1 == 0x08 then 
        jump wipe_xy_and_return
    if v1 == 0x09 then
        jump wipe_xy_and_return
    save v1

    # If we are still here, redraw the bullet:
    draw_bullet             # call 0x05E6
    # This will leave X Y in v0 v1

    # if bullet exists, did a collsion occur? 
    # this code structure means we will return if v0 is 0, and skip the return if vF is 0
    if v0 != 0x00 then
        if vF == 0x00 then  # vF check only called if bullet X coord != 0
    return

# 0x05E6
: draw_bullet
    # This function reads the player bullet coordinate and draws/undraws it
    i := memory_player_bullet_xy
    load v1             
    i := sprite_bullet      
    if v0 != 0x00 then      
        sprite v0 v1 0x03   
;                   

and

# vF use while servicing enemy bullet:

# 0x03A4
: move_enemy_bullet
    draw_enemy_bullet # :call 0x0474
    v1 += 0x04
    i := memory_enemy_bullet_xy
    save v1
    draw_enemy_bullet # :call 0x0474

    if v1 == 0x40 then
        jump enemy_bullet_run_offscreen
    if v1 == 0x3F then
        jump enemy_bullet_run_offscreen
    if v1 == 0x3E then
        jump enemy_bullet_run_offscreen
    if v1 == 0x3D then
        jump enemy_bullet_run_offscreen

    if v1 == 0x2B then
        jump enemy_bullet_hit_wall
    if v1 == 0x2A then
        jump enemy_bullet_hit_wall
    if v1 == 0x29 then
        jump enemy_bullet_hit_wall
    if v1 == 0x28 then
        jump enemy_bullet_hit_wall
    if v1 == 0x27 then
        jump enemy_bullet_hit_wall

    # Detect if it has hit the player
    # (it goes on to work out if it hit their bullet via math)
    if vF == 0x00 then
        return

# ...

# 0x0474
: draw_enemy_bullet
    # Leaves X and Y coord in v0 and v1
    # Sets vF for hitting something
    # Always draws a bullet
    i := memory_enemy_bullet_xy
    load v1
    i := sprite_bullet
    sprite v0 v1 3
return

One important note: this game was not written for the HP48 interpreters, and was likely never tested on that platform by the author, it does depend on several superchip quirks, specifically bit shifting ignoring vY, and I remaining static on save/load. Given it was made in '92, one could guess that that the author would have tried other superchip games of that era. Perhaps implementing a vF = Carry quirk for FX1E or 7XNN, and seeing if well known games from that time suffer ill effects could hint at which instruction would be the best candidate.

# Example Quirk reliance, 0x02EA
    i := memory_time_bonus # i := 0x0C3F
    load v0
    v0 >>= v5
    v0 >>= v5
    v0 >>= v5
    save v0

I must say, I actually thoroughly enjoyed pulling the game apart and learning the approach the developer, Slammer (Carsten Soerensen) took, and the enemy sprites are great. Thank you for bringing this to my attention.

tobiasvl commented 4 years ago

Nice!

So, most likely there is a quirk in the game author's own Amiga interpreter.

I'm not sure if the author of the game has his own Amiga interpreter, but it seems there is one from as early as 1990, made by Paul Hayter.

Thank you for bringing this to my attention.

And thank you for the thorough sleuthing.

Chromatophore commented 4 years ago

To set my own mind at ease, I figured I'd validate that 7XNN did not affect vF on any superchip version, even though we know this game targets the schip 1.1 instruction set due to scrolling.

i7: ; 7XKK, add constant to variable
    call.3  varpd0  ; get pointer to X
    add.a   2,d1    ; point to second byte of instruction
    move.b  @d1,a   ; now a = KK
    move.b  @d0,c   ; get old value
    add.b   a,c ; add KK
    move.b  c,@d0   ; store new value
    retclrc

That is the code for instruction 7XNN, which adds a byte directly to a register, in superchip 1.0's source code.

This clearly does not interact with vF in any special way.

This is the code for the 7XNN instruction disassembled from superchip 1.0:

006B7  GOSUB   003F7 # call varpd0 subroutine
006BB  D1=D1+  2
006BE  A=DAT1  B
006C1  C=DAT0  B
006C4  C=C+A   B
006C7  DAT0=C  B
006CA  RTNCC

and the same routine in superchip 1.1:

007DF  GOSUB   00351 # call varpd0 subroutine
007E3  D1=D1+  2
007E6  A=DAT1  B
007E9  C=DAT0  B
007EC  C=C+A   B
007EF  DAT0=C  B
007F2  RTNCC

Superchip 1.1 actually has a custom savecarry instruction, at 003C7 that gave me some trouble in the past. However, it is only called by certain ALU ops (and all uses of it are preceeded by a call to ALUsetup, indicating it is a command in an #XY# format.

In any case, the code didn't change, and again there's no extra interaction with vF in any version I can find, and, just playing the game on the calculator exhibits the bug pretty plainly if you look for it.

Chromatophore commented 4 years ago

Anyway, I took a day to sort of I guess reflect on what I was thinking and, well, I think I want to reconsider some of my statements.

I mistakenly thought the author of the game had written an Amiga interpreter, rather than an assembler (a tool that works with more human readable assembly code). So now, things are pretty unclear. I guess I don't know 100% which interpreter was targeted by SpaceFight! 2091. But, consider the following:

The game does not work at all without these instructions and quirks - you can't get out of the title screen or fire a bullet, even. Whatever interpreter it was played with, it would have to be contemporary to 1992, and which exhibited those features/bugs.

We have to acknowledge that perhaps it was played with SCHIP 1.1 on an HP48. Maybe the author diligently tested and played it over idk a weekend and had fun with it. Maybe they always played the game the same way, killing enemies from left to right, or only occasionally saw the bug. Maybe he knew a byte to change from 5 to 1, to have only one enemy to fight, to quickly progress through the game and test everything worked. Like this dude had an Amiga and perhaps a calculator? Maybe it just slipped by him?

While we can look at the code now and say 'yes of course, this would be completely* fixed if only X was true!' - but... maybe it was just a bug with the game? I mean, maybe he even fixed it like 20 minutes after sharing whatever version we have now and that other version didn't get saved. Probably shouldn't put some guy's 2 day hobby project on like a shrine.