Skip to content

surprising behavior when assembling instructions dependent on operand bitlength #47

@notwa

Description

@notwa

on certain CPUs, like the NES's clone of the MOS 6502, there are redundant instructions for reading and writing the zero page in memory, i.e. any address less than 256. these instructions typically save 1 byte and 1 cycle over their longer counterparts. when assembling these instructions, bass struggles with which opcode to use, sometimes even incorrectly truncating their operands.

consider the assembly code:

arch nes.cpu
output "dummy.bin", create

// the same number in different bases:
constant foo = 1234
constant bar = $4D2

sta foo // 8D D2 04, correct
sta bar // 8D D2 04, correct

so far so good: bass uses the "longer" opcodes for high memory addresses. let's say foo and bar might be the X and Y coordinates of onscreen objects in a game, and we'd like to take offsets in memory for known objects, as this kinda scenario is typically where I run into this issue.

sta foo+1 // 8D D3 04, incorrectly 85 D3 on v19
sta bar+1 // 8D D3 04, incorrectly 85 D3 on v19

(v18 = master branch, commit a847dfc. v19 = devel branch, commit 5617511)
bass v19 is truncating the address and dropping the upper byte entirely! bass v18 appears to be doing the right thing, but that's just a fluke — I'll show that later. for now, let's look at how bass treats literals in expressions instead of constants.

// different behavior for different bases when inlined:
sta 1234 // 8D D2 04, correct
//sta $4D2 // 8D D2 04, correct (does not assemble on v18)
sta $04D2 // 8D D2 04, correct
sta 1234+1 // 8D D3 04, correct (but see the next section)
sta $4D2+1 // 8D D3 04, incorrectly 85 D3 on v19

those last two lines had me deeply confused until I stared at the Table::bitlength function in table.cpp (in v19). the inconsistency here is due to atoi and hexLength/binLength acting differently when they encounter the + symbol: atoi stops parsing and ultimately results in the bitlength of the number up to that point. the other methods simply exit and return 0. knowing how this works, we can develop more surprising examples:

// bases are parsed differently:
// Table::bitlength returns 0 on v18, and 8 on v19:
sta 255+2 // 8D 01 01, incorrectly 85 01 on v19
// Table::bitlength returns 0 on both versions:
sta $FF+2 // 8D 01 01, incorrectly 85 01 on v19
// Table::bitlength returns 0 on v18, and 9 on v19:
sta 260-5 // 85 FF, incorrectly 8D FF 00 on both versions
// Table::bitlength returns 0 on both versions:
sta $104-5 // 85 FF, incorrectly 8D FF 00 on v18

now, why does v18 seem to do the right thing more often? well, it's a fluke. in v18, nes.cpu.arch contains this:

sta *16        ;$8d =a
sta *08        ;$85 =a

and v19 contains this:

sta *08        ;$85 =a
sta *16        ;$8d =a

bass prefers whichever entry comes first. in v18, when the bitlength is 0, the first one is always chosen. 0 "fits into" 16, and the $85 entry is never considered. this means that v18 should always produce valid code, but not always the code we expect.

finally, let's look at a subroutine from the disassembly of SMB 1 that's been floating around online (and converted to bass syntax):

constant Block_X_Speed     = $60
constant Block_PageLoc     = $76
constant Block_X_Position  = $8F
constant Block_Y_Speed     = $A8
constant Block_Y_Position  = $D7
constant Block_Orig_XPos   = $03F1
constant Block_Y_MoveForce = $043C
// [snip]
SpawnBrickChunks:
lda Block_X_Position,x
sta Block_Orig_XPos,x
lda #$F0
sta Block_X_Speed,x
sta Block_X_Speed+2,x
lda #$FA
sta Block_Y_Speed,x
lda #$FC
sta Block_Y_Speed+2,x
lda #$00
sta Block_Y_MoveForce,x
sta Block_Y_MoveForce+2,x
lda Block_PageLoc,x
sta Block_PageLoc+2,x
lda Block_X_Position,x
sta Block_X_Position+2,x
lda Block_Y_Position,x
clc
adc #$08
sta Block_Y_Position+2,x
lda #$FA
sta Block_Y_Speed,x
rts

as it is, neither bass v18 nor bass v19 produce matching code from the original game. the simplest workaround for both versions is to add < and > characters to each operand (not the constants) to override their bitlengths to be 8 and 16 respectively, but this still requires some mental bookkeeping, as well as Find+Replace if I decide to move an address in or out of zero-page.

PS. thank you to everyone for maintaining bass over the years. I hope my write-up doesn't come across as overly critical; I'm just being verbose in hopes of saving anyone else the confusion. plus, there are a lot of potential test-cases here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions