An odd bit of undefined behavior

Normally, if you try to compute a result that is outside Dialog’s numerical range, the predicate simply fails.

The one exception is multiplication, because of a limitation of the Z-machine. There’s no practical way to figure out if 16-bit multiplication is going to overflow, or has overflowed.

Which means that this code:

(program entry point)
	10000 times 10000 is
	(if) (10000 times 10000 into $Result) (then)
		$Result
	(else)
		not computable
	(endif)

Produces:

10000 times 10000 is 8448

This result is guaranteed in the debugger and on the Å-machine, and it also happens in every Z-machine interpreter I’ve tried (but is not guaranteed there).

2 Likes

You can catch some overflows by multiplying the numbers divided by a constant (e.g. 64), and checking the absolute value of the result is < 8 or so (I’m too tired to get the math right). But there are edge cases which that method won’t detect.

Yeah, my naïve thought would be to just divide the result by one factor and see if you get the other, but there must be some non-trivial case where this doesn’t work? Dialog is otherwise so fastidious about avoiding undefined behavior, it’s very weird to have this one exception unless it really can’t be avoided on Z-machine.

That reminds me, I actually have used that technique myself!

I’m sure it will work without issues.

p.s. it is probably not that useful, but my Glulx code looked like this:

bi_clamped_multiply:
	function
	local	A B

	; when either value is zero, result is zero
	jz	A -> .zero
	jz	B -> .zero

	; determine sign of result (in top bit)
	local	sign
	bitxor	A B -> sign

	; get absolute value of each term
	jge	A 0 -> .over1
	neg	A -> A
.over1:
	jge	B 0 -> .over2
	neg	B -> B
.over2:
	; perform multiply
	local	C
	mul	A B -> C

	; basic overflow check
	jgtu	C MAX_INTEGER -> .overflow

	; perform a division to verify the multiply worked
	div	C B -> B
	jne	A B -> .overflow

	; okay!

	; apply the sign
	jge	sign 0 -> .done
	neg	C -> C
.done:
	return	C
.zero:
	return	0
.overflow:
	jlt	sign 0 -> .underflow
	return	MAX_INTEGER
.underflow:
	return	MIN_INTEGER
1 Like

For completeness, the alternative (and conceptually simpler to me) approach is to “just” compute the full-width multiplication result and check that for overflow before returning the truncated result. That sounds expensive as well, but if you only want the overflow check, it turns out you can skip a lot of the work.

I’m not sure if it’s faster than the “divide result by one factor” approach. It’s probably more Z-machine instructions, but division is generally much more expensive than multiplication, including on retro hardware, so it may still come out ahead.

Once I started writing this post I got nerd-sniped into actually finishing the derivation, so it’s gotten rather long.


The Z-machine doesn’t have 16x16 → 32 bit multiplication, but we can build it from what does exist. This is simplified by Dialog only using the non-negative 14-bit integers, so we can conveniently ignore the complications of negative numbers and even side-step the mismatch with the Z-machine’s signed arithmetic. The standard way to build a full-width multiply of word-sized inputs L, R is to split them into halves and do long multiplication in base (word size / 2).

For Dialog that means we can compute ($LHS times $RHS into $Result) as base-128 long multiplication. For non-negative n < 16383, let n₀ be the least significant seven bits and n₁ the most significant bits, thus n = n₁ * 128 + n₀. (This requires bit shifts, but luckily Dialog targets Z-machine v5 or v8 which have some shift opcodes.) Then we could compute L * R as follows:

   L * R
= (L₁ * 128 + L₀) * (R₁ * 128 + L₀)
= (L₁ * R₁ * 128²) + (L₁ * R₀ * 128) + (L₀ * R₁ * 128) + (L₀ * R₀)

Ignoring the factors of 128 (bit shifts), each of the products of parts of L and R can be computed with Z machine mul instructions, so this reduces the multi-precision arithmetic to add-with-carry (also not natively supported, but easy to emulate with normal add and overflow checks). If we needed the full-width multiplication result, that’s basically as good as it gets. But we can simplify it since we’re only interested in whether the product overflows (if it doesn’t, the Z-machine mul instruction gives the right result):

  • If first term L₁ * R₁ * 128² is non-zero, the product definitely overflows because 128² > 16383. So we don’t need to compute this term, only check if L₁ and R₁ are non-zero.
    • If both L₁ and R₁ are non-zero, we have overflow.
    • Conversely, if L₁ = R₁ = 0, then there is no chance of overflow, as the result is at most 127² < 16383. (Special-casing this isn’t strictly required, but makes the rest of the derivation a little simpler.)
  • That leaves the case where either L₁ or R₁, but not both, is zero. I’ll only discuss the L₁ = 0 case, the case R₁ = 0 is symmetric.
    • The first and second terms (involving L₁) are zero, thus L * R = (L₀ * R₁ * 128) + (L₀ * R₀).
    • Since the high bits of L are zero, we don’t need to explicitly isolate the low bits of L, i.e., we have L = L₀ and can write L * R = (L * R₁ * 128) + (L * R₀).
    • Overflow will mostly be due to the first term (L * R₁ * 128) being too large, as the contribution of (L * R₀) is much smaller. We’ll ignore (L * R₀) for now and get back to it later.
    • In general (L * R₁ * 128) won’t fit in 16 bits, but we don’t need to compute it to detect overflow. L * R will overflow if L * R₁ > 16383 / 128. Since that right-hand side isn’t an integer, it’s more obvious to write this as L * R₁ >= (16383 + 1) / 128 = 128.
    • To correct for the contribution of (L * R₀) and make the check precise, we actually check if (L * R₁) + ((L * R₀) / 128) >= 128. Since the fractional part of (L * R₀) / 128 never changes the comparison result, we only need floor((L * R₁) / 128), which is just a multiply and bit shift ((L * R₁) >> 7).

So in summary, we only need two additional mul instructions (over the one used to compute the actual result), and a few shifts/adds/compares. Here’s a Rust program implementing this and checking that it actually works correctly:

fn dialog_mult_overflow(lhs: i16, rhs: i16) -> bool {
    let l1 = lhs >> 7;
    if l1 == 0 {
        // optional: short-circuit if rhs small enough (r1 == 0)
        let l0 = lhs;
        let r0 = rhs & 0x7f;
        l0 * r1 + ((l0 * r0) >> 7) >= 128
    } else {
        let r1 = rhs >> 7;
        if r1 != 0 {
            // both >= 128, so product must overflow
            return true;
        }
        let l0 = lhs & 0x7f;
        let r0 = rhs;
        (l1 * r0) + ((l0 * r0) >> 7) >= 128
    }
}

fn main() {
    for lhs in 0..=16383 {
        for rhs in 0..=16383 {
            let real_result = i64::from(lhs) * i64::from(rhs);
            let ovf_real = real_result > 16383;
            let ovf_diag = dialog_mult_overflow(lhs, rhs);
            assert_eq!(ovf_real, ovf_diag, "{lhs} * {rhs} = {real_result}");
        }
    }
}
2 Likes

My z-machine interpreter produces a diagnostic on all overflows, but that isn’t something the game code could inspect.

So that raises a new issue—

Is there a good reason to keep the current system? (I think it’s technically unspecified behavior, not undefined behavior; the spec says the predicate always needs to return some value, but doesn’t specify what that value should be.) In general, asking Dialog to compute something it can’t represent just makes the predicate fail; as far as I know, multiplication is the one place where it returns an incorrect result instead.

On Å-machine, this behavior is in fact specified: the MUL_NUM instruction returns (A × B) & $3FFF. But I’m struggling to think of cases where this behavior would be desirable, beyond using the overflow to implement a hash function or PRNG.

Would it be better to change the behavior going forward, and have ($ times $ into $) fail on overflow, just like ($ plus $ into $) or ($ minus $ into $) do? This is especially relevant now as we figure out what the 32-bit behavior should be.

  • Overflow should make the predicate fail
  • Overflow should return an incorrect value
  • I don’t care
0 voters
1 Like

There is always the ugly AF let’s turn multiplication into addition and let addition tell you when it overflows. I think SO by recursion can also be avoided. Someone recently said that recursion is converted to tail recursion if it is done a certain way. Crude, yes, solves the problem, yes. How often do you need math like this in Dialog anyway? Is it common?

Not super common, but it shows up enough that it’s good for it to work the way people expect. Dialog’s math support is (by design) powerful enough to do pretty much whatever you need, but clunky enough that you don’t want to unless necessary. Sue Davis is doing trigonometry in it.