It appears there is a bug in Ultima 2 that has to do with vendor prices as a player approaches maxed stats. Both Charisma and Intelligence affect shop prices such that higher values should lower vendor prices. This is fine until a certain point, but after your stats are raised above a certain threshold it appears that prices skyrocket again.
Determining Prices
Let’s take a look at part of the code that determines prices:
20c2 mov al,[0050] ; get int from PLAYER data 20c5 adc al,[004e] ; add in charisma
Here the game code grabs the Int and Cha values from the PLAYER data and adds them. There are two problems with how this is done but we’ll come back to that. It then uses the Int+Cha sum in the next set of instructions:
20c9 mov bh,00 20cb mov bl,00 20cd mov si,bx ; si = 0 20cf inc si ; inc bit counter 20d0 clc ; clear carry flag from last iteration 20d1 rcr al,1 ; right-roll al by 1 bit into carry 20d3 or al,al 20d5 jnz 20cf ; loops until al==0
Here the game is counting the number of bits in the Int+Cha sum. How many bits get counted determine the price “tier”. So, a value of al=0x20 is 00100000 which would result in a bit count of 6. A horse at this price tier costs 84 gold. Knowing that the bit count determines price allows us to build out a table of price tiers. I’ve omitted the price calculation logic as it’s long and not directly relevant to our troubleshooting.
- sum >= 10, bits = 5, horse = 136 gold
- sum >= 20, bits = 6, horse = 84 gold
- sum >= 40, bits = 7, horse = 52 gold
- sum >= 80, bits = 8, horse = 32 gold
The Problems
Back to the bug and the first two lines starting at offset 20c2. The first problem is that both values are stored in the PLAYER file as Packed Binary Coded Decimal (BCD). The ADC instruction performs hexadecimal arithmetic unless followed by DAA instruction, which adjusts for BCD values. But since there’s no DAA the math gets a bit screwy. If you have stats Int=20 and Cha=20 your sum in both hex and decimal is 40 which pushes you to tier 7. But if you have Int=15 and Cha=25 your sum in decimal should also be 40, but in hex is 0x3a which is in tier 6.
The second thing to notice is that we’re summing two values with a max of 99 in an 8-bit register. 0x99 + 0x99 = 0x132 which results in an overflow because the extra “1” can’t fit. So the price logic actually determines your tier based on the value 0x32.
The Solution
Fortunately there’s some space here to add corrective logic at offset 20c9. It currently takes 6 bytes to set register si to 0, which we really only need 2 bytes for. And we can gain another byte by compacting the CLC/RCR instructions into SHR. So I replaced it with the following.
20c9 daa ; do bcd adjustment 20ca jnc 20ce 20cc mov al,99 ; max sum at 99 only if carry is set 20ce and si,si ; si = 0 20d0 inc si ; inc bit counter 20d1 shr al,1 ; right-shift al by 1 bit 20d3 or al,al 20d5 jnz 20d0 ; loops until al==0
Here, we fix the BCD math issue by adding the DAA instruction to do proper decimal addition. Second, we check for an overflow by looking at the carry flag right after computing the sum. If carry is set then we just max out the sum at 0x99.
I’ve checked this fix into my v2.1 branch.
I’m just looking at the U2 price computation and, really, the game’s code is a mess. The way they carelessly mix BCD and standard arithmetic is but one of the many smelly ouchy things, and it’s not a nice one to reverse-engineer. So, hats off and thanks for a great upgrade. (Although of course it doesn’t fix the main flaw of the game — its design 😉
Just one thing:
20ce and si,si ; si = 0
I’m sure you mean XOR, because AND si, si yields si….
Thanks, I’ll have to review what I did again but think you may have found a bug in my code. I appreciate the code review. 🙂