(2025-05-19) How I ported Bulls and Cows to HP 12C and why it is important -------------------------------------------------------------------------- Yes, I promised to return to the topic of tiny VMs in the next post but I still think this story needs to be told sooner, and you can, by the way, consider this one a direct precursor to the next post about tiny VMs. Because it is about a game that's my all-time favorite when it comes to numeric-only I/O, and can (arguably) be viewed as the ultimate benchmark that sets apart computers from calculators. Yes, Bulls and Cows return, now on the programmable financial HP 12C I told you about in the previous post. If you just want a short form listing, the setup and the gameplay rules, this game has already been published in my HP 12C software collection text document ([1]) under the name "SlowMoo12". But I assume everyone who's been reading my phlog for some time already knows how to play Bulls and Cows. Today, I want to discuss all the technical challenges that I had to overcome in order to fit it into 99 program steps of a not very advanced instruction set, with just the seven main registers and the five financial registers left at my disposal. At some moments, the mission felt impossible but I still managed to accomplish it. Let's talk about how and then get to why. But first, let's break down the design of the game itself on the high level. Any Bulls and Cows session with a computer consists of two parts: one where the computer generates a random number and one where the player tries to guess the generated number. What do we know about the first part? Well, we know two requirements: the generated number must contain four decimal digits (and can start with 0) and all digits must be unique. So, we need to generate a digit, check if it hasn't already been used and append (or prepend, doesn't matter) it to the number if it hasn't been used yet. How do we check for uniqueness? This is where the first challange comes. In the mu808 and n808 versions of Bulls and Cows, I had a pool of digits from 0 to 9 in separate memory cells. Then, I generated a random position from 0 to 9 and extracted the digit from the corresponding cell. If the cell already contained 10, I retried with a different position. If it didn't, I took the digit from there and put 10 instead. This way, the result was guaranteed to have four unique digits. However, this approach won't work on the 12C at all: we don't have indirect addressing and we don't even have enough cells to start with. We do, however, have a ten-digit (visible) register size, so we can save the digit pool within a single register just by writing 1234567890 in there. Once we have this value in a register, we need to generate a random position between 0 and its length. How do we know its length? We know that it will be 10 for the first iteration, 9 for the second iteration, 8 for the third iteration, and 7 for the fourth (and last) iteration. Since our iteration counter I starts with 4 and then decrements (with the condition to get out of the loop when it gets to 0), we know that the pool number length is just I + 6. So, we've generated the position P as a random integer number in the [0; I+6) interval, now what? Now we need to do two things: extract the digit at that position and shrink the pool number to not include that digit anymore. Digit extraction is seemingly easy: calculate the factor F in the form of 10^P, divide the pool number N by F, take the integer part of the result and the digit will be that integer part modulo 10. However, another hurdle arose: the 12C does not have any modulo operation, only the operations to take the integer (INTG) and fractional (FRAC) parts. So, instead of going all the way to obtaining the modulo, we divide the integer part by 10 and store the digit as the fractional part of that as it is (from 0 to 0.9). Then, we update the target number T (initially set to 0) by shifting it 1 position to the right and adding this decimal fraction representing the new digit. So, with the pool number N and position P: F = 10 ^ P DP = INTG(N/F) D = FRAC(DP/10) T = T/10 + D That's why, by the way, we naturally obtain the guess numbers between 0 and 1. Now, we need to shrink the pool number N. Remember that we have the factor F stored and also the integer part (DP) we got from dividing N by that factor. So, essentially, we need to divide DP further by 10, take an integer part from that, add the fractional part of N/F and then multiply the result by the factor. Using only the above variables, that comes down to: N = N + (INTG(DP/10) - DP) * F It might also be helpful to cache the DP/10 value since we're calculating both integer and fractional parts from it. To sum it up, the pseudocode (a bit BASIC-like but in the terms close to the 12C's instruction set) for generating all four digits in the result T looks like this: N = 1234567890 T = 0 I = 4 :GENLOOP F = 10 ^ INTG(RAND() * (I+6)) DP = INTG(N/F) DQ = DP / 10 T = T/10 + FRAC(DQ) N = N + (INTG(DQ) - DP) * F I = I - 1 IF I = 0 THEN GOTO :MAIN ELSE GOTO :GENLOOP :MAIN ... OK, we've got the resulting unique 4-digit number in R (which, along with the PRNG implementation itself, took 46 steps of the real 12C program, and that's considering 1234567890 and 10 being set in the registers outside the program as a part of the first-run game setup). But that just was the first part of the game. The second part is the main loop where we prompt the player for the number to guess and then score the input by matching all digits. This seems to be straightforward: just set up two loops, increase the number of bulls if the digits and the indices match, increase the number of cows if just the digits match and the indices is different... but to do this in a size-efficient way is quite a challenge of its own. In order to actually compare the numbers digit by digit, we shift each of them by multiplying by 10 and taking the integer part, then, again, using the fractional trick on their integer parts difference divided by 10. We don't need the actual digit values to be whole, just need to get 0.0 difference to spot a match. If a match has been spotted, we increase the score by 1, but also increase it by another 9 if the indices are equal too. Finally, we restore the guess number after we've fully gone through the inner loop and the target number after we've fully gone through the outer loop by dividing them by 10000 (another constant that had to be placed into a register during the first-run setup). As a "last-minute change", I decided to actually move the guess value adjustment to the point _before_ the inner loop starts, so that the player actually can enter just four digits without the decimal point. This wasn't present in the first version of the game, by the way. In the pseudocode, the main routine looks like this. Let's call the outer and inner loop counters I and J, the target number T and the guess input G: :MAIN G = INPUT() I = 4 SCORE = 0 :OUTERLOOP G = G / 10000 T = T * 10 TI = INTG(T) J = 4 :INNERLOOP G = G * 10 C = FRAC((INTG(G) - TI)/10) IF C = 0 THEN GOTO :IDXCOMP ELSE GOTO :INNEREND :IDXCOMP D = I - J IF D = 0 THEN SCORE = SCORE + 10 ELSE SCORE = SCORE + 1 :INNEREND J = J - 1 IF J = 0 THEN GOTO :OUTEREND ELSE GOTO :INNERLOOP :OUTEREND I = I - 1 IF I = 0 THEN GOTO :SCORESHOW ELSE GOTO :OUTERLOOP :SCORESHOW T = T / 10000 OUTPUT(SCORE) GOTO :MAIN As such, the entire Bulls and Cows game can effectively be described with the following algorithm (that should work on any platform supporting 10 digits before and 4 after the decimal point): N = 1234567890 T = 0 I = 4 :GENLOOP F = 10 ^ INTG(RAND() * (I+6)) DP = INTG(N/F) DQ = DP / 10 T = T/10 + FRAC(DQ) N = N + (INTG(DQ) - DP) * F I = I - 1 IF I = 0 THEN GOTO :MAIN ELSE GOTO :GENLOOP :MAIN G = INPUT() I = 4 SCORE = 0 :OUTERLOOP G = G / 10000 T = T * 10 TI = INTG(T) J = 4 :INNERLOOP G = G * 10 C = FRAC((INTG(G) - TI)/10) IF C = 0 THEN GOTO :IDXCOMP ELSE GOTO :INNEREND :IDXCOMP D = I - J IF D = 0 THEN SCORE = SCORE + 10 ELSE SCORE = SCORE + 1 :INNEREND J = J - 1 IF J = 0 THEN GOTO :OUTEREND ELSE GOTO :INNERLOOP :OUTEREND I = I - 1 IF I = 0 THEN GOTO :SCORESHOW ELSE GOTO :OUTERLOOP :SCORESHOW T = T / 10000 OUTPUT(SCORE) GOTO :MAIN This, however, is just pseudocode. Implementing it in the actual 12C instruction set required quite a lot of non-trivial thinking. Let's first perform some basic clearing: 01 CLx ; get 0 02 STO 4 ; the target number T will be in R4 03 RCL PV ; recall the 1234567890 constant 04 STO 2 ; copy into the operating register R2 as N 05 4 ; push 4 as the iteration counter I 06 STO 0 ; save it into R0 Let's now see how the RAND() function is implemented. We'll use the 7-step generator described in my previous post (using Rn as the preset seed value from 0 to 1): 07 RCL n ; generation loop start, recall Rn 08 FRAC ; and take its fractional part 09 3 ; push 3 10 + ; add 3 11 e^x ; take its natural exponent 12 12x ; multiply by 12 and rewrite Rn 13 FRAC ; return its fractional part for further usage Then we convert the value returned by the generator into our factor value (assuming the iteration counter I already is in R0 and the constant 10 is in R5): 14 6 ; push 6 15 RCL 0 ; push the iteration counter 16 + ; add it 17 x ; multiply by the generator output 18 INTG ; get the integer index of the target digit 19 RCL 5 ; push 10 20 x<>y ; prepare for 10 ^ index 21 y^x ; calculate it 22 STO 1 ; store the factor F into R1 Yes, it took 16 steps just to calculate the F = 10 ^ INTG(RAND() * (I+6)) line from the algorithm. In 15C with its built-in PRNG, it would take 10 at most. But we're prepared to carry that burden, so let's move on. Here, we just calculate DP = INTG(N/F): 23 RCL 2 ; recall N 24 x<>y ; swap the args 25 / ; divide N by F (already in the stack) 26 INTG ; get DP That's 26 steps in total already. Now, the problem is that we don't have enough registers to store both DP and DQ in, so we resort to some stack manipulation to calculate these lines: DQ = DP / 10 T = T/10 + FRAC(DQ) N = N + (INTG(DQ) - DP) * F In the actual code (using (X Y Z T) stack diagrams to make the picture clearer): 27 ENTER ; push DP 28 ENTER ; (DP DP - -) 29 RCL 5 ; (10 DP DP -) 30 STO / 4 ; T = T/10 31 / ; (DQ DP - -) 32 FRAC ; (FRAC(DQ) DP - -) 33 STO + 4 ; T = T + FRAC(DQ) 34 LSTx ; (DQ FRAC(DQ) DP -) 35 - ; (-INTG(DQ) DP - -) 36 + ; (DP-INTG(DQ) - - -) 37 CHS ; (INTG(DQ)-DP - - -) 38 RCL 1 ; recall the factor F 39 x ; F * (INTG(DQ) - DP) 40 STO + 2 ; N = N + F * (INTG(DQ) - DP) 14 more steps. But it's time to finish the loop. 41 1 ; push 1 42 STO - 0 ; I = I - 1 43 RCL 0 ; recall the I value from R0 44 x=0 ; if reached 0 45 GTO 47 ; go further 46 GTO 07 ; otherwise repeat the loop Now, after the long journey of 46 steps, we finally have our desired unique four digits abcd in the 0.abcd format in R4. We're ready to prompt the player to enter the number to guess, right? Well, almost. We need to clear the score first, which is gonna be stored in R3. We can make use of the fact the X register now contains 0, but at the end of the loop it will contain the score itself. Then, we input the guess G and store it into R2: 47 STO - 3 ; UI loop start, clear the score register R3 48 R/S ; prompt for the guess number G 49 STO 2 ; store it into R2 Then, let's store 4 into the outer loop counter I in R0: 50 4 ; push 4 51 STO 0 ; store it in R0 (outer loop counter) Start the outer loop by setting up the same for the inner loop counter in R1 and adjusting the guess number G to move it into the correct decimal range (either after the initial input, or after previous multiplications): 52 4 ; outer loop start, push 4 53 STO 1 ; store it in R1 (inner loop counter) 54 EEX ; push 10000 55 4 56 STO / 2 ; restore the guess number G value Then, perform the T = T * 10 and TI = INTG(T) steps (storing the TI value in R6): 57 RCL 5 ; recall 10 58 STO x 4 ; multiply T (in R4) by 10 in place 59 RCL 4 ; recall T 60 INTG ; get the integer part of the new T value (TI) 61 STO 6 ; store the TI value in R6 Now, we can start the inner loop. First we do the same as for the outer loop but with a twist: 62 RCL 5 ; inner loop start, recall 10 63 STO x 2 ; multiply G (in R2) by 10 in place 64 RCL 2 ; recall G 65 INTG ; get the integer part of the new G value 66 RCL 6 ; recall the TI value The twist is, we don't need to store the INTG(G) value anywhere as we already have it in the stack register Y, and the previously stored TI value is now in the register X, so we can just continue with our algorithm by computing C = FRAC((INTG(G) - TI)/10): 67 - ; INTG(G) - TI 68 RCL 5 ; push 10 69 / ; (INTG(G) - TI)/10 70 FRAC ; C = FRAC((INTG(G) - TI)/10) Again, the C value is ephemeral because we only need it to compare to 0, which we do right now: 71 x=0 ; compare C to zero 72 GTO 74 ; proceed to compare indices if it is zero (the guess and target digits match) 73 GTO 83 ; go to the inner loop finalization if it isn't Now, we perform the index comparison and scoring logic in case a match is found, and some advanced stack manipulation also is necessary here to save crucial step count: 74 RCL 5 ; push 10 beforehands (10 - - -) 75 1 ; push 1 beforehands (1 10 - -) 76 RCL 0 ; recall the outer index I (I 1 10 -) 77 RCL 1 ; recall the inner index J (J I 1 10) 78 - ; compare them (I-J 1 10 10) 79 x=0 ; if they are equal 80 ROLL ; (1 10 10 0) 81 ROLL ; the stack top will be 10 if they are equal, 1 if not 82 STO + 3 ; add to the score in R3 The trickery here is with the correct stack setup. When the x=0 condition is run, the stack is (I-J 1 10 10). If I-J is NOT 0, only one roll is performed, which makes the state (1 10 10 I-J) and returns 1 at the top. But if I-J actually is equal to 0, then the second roll will bring the stack to the (10 10 0 1) state, with 10 at the top. This top value is what finally gets added to the score variable in R3. 83 1 ; end of the inner loop, push 1 84 STO - 1 ; decrement the inner index 85 RCL 1 ; recall the inner index 86 x=0 ; if 0, then 87 GTO 89 ; go to the outer loop end 88 GTO 62 ; else repeat the inner loop Now, we can finalize the outer loop too, not forgetting to reset the G value to its initial state by multiplying by 10000: 89 1 ; outer loop end, push 1 90 STO - 0 ; decrement the outer index 91 RCL 0 ; recall the outer index 92 x=0 ; if 0, then 93 GTO 95 ; go to show the score 94 GTO 52 ; else repeat the outer loop We've lastly come to the final part, where we prepare for the next guess iteration by restoring the T value and showing the score before looping back to the new prompt: 95 EEX ; push 10000 96 4 97 STO / 4 ; restore the target number T 98 RCL 3 ; show the score 99 GTO 47 ; go to the new input prompt This is it. This is how it has been done, from the idea to the pseudocode to the exact 99 steps taken by the program. I could make it a bit less but that would make the game require more setup than just f REG [seed] n 1234567890 PV 10 STO 5 and still wouldn't make the program take less than 92 steps in order to free an extra register. Anyway, I'm pretty satisfied with the result, considering it has been achieved on a mere financial calculator with no internal PRNG or advanced programming features like indirect addressing, subroutines or loop helpers, with very few conditional instructions and limited storage arithmetic capabilities. And this, by the way, answers the "why" question: because I wanted to prove the point that a normal, classic HP 12C is also capable of running such a game, and that fact, despite all its limitations, puts it into a class much above "normal" scientific or financial calculators. Again, even though it has nowhere near the same programming capabilities as the MK-52/61, 15C or even 11C, it still is lightyears ahead of what e.g. Casio fx-3400P or Citizen SRP-145 programmable calculators could offer. As I already said, if it can run Bulls and Cows which can be programmed on the device itself, it can be considered a pocket computer. A very limited one, but a computer nonetheless. You won't get this game on a Casio fx-3400P, no matter how hard you try. Because it's just a totally different class of devices in a similar form factor. And now, just for fun, here's a port of the very same algorithm (with slight input/output decorations) to the TI-74 BASIC: 10 RANDOMIZE:N=1234567890:T=0 20 FOR I=10 TO 7 STEP -1 30 F=10^INT(RND*I) 40 DP=INT(N/F) 50 DQ=DP/10 60 IDQ=INT(DQ) 70 T=T/10+DQ-IDQ 80 N=N+(IDQ-DP)*F 90 NEXT I 100 INPUT "Guess:";G 110 IF G<=0 THEN 270 120 BS,CS=0 130 FOR I=1 TO 4 140 G=G/10000 150 T=T*10 160 TI=INT(T) 170 FOR J=1 TO 4 180 G=G*10 190 C=(INT(G)-TI)/10 200 IF INT(C)<>C THEN 220 210 IF I=J THEN BS=BS+1 ELSE CS=CS+1 220 NEXT J 230 NEXT I 240 T=T/10000 250 PRINT USING "Bulls: #, cows: #",BS,CS:PAUSE 260 GOTO 100 270 PRINT USING "The number was ####, bye!",T*10000:PAUSE 280 END The gameplay is the same, except you can also type 0 (or any negative number) to give up, get the final number and exit the game. The game takes up just 447 bytes out of the total 7710 in the internal TI-74S memory, but it just shows how drastically a slight increase of computing power simplified programming capabilities. The TI-74 is just as far ahead of the 12C as the 12C is ahead of the fx-3400P. And the HP 41C, I guess, was somewhere in between the two, but closer to the TI-74, especially when it came to peripheral support. The latter, by the way, had been mostly ditched in the 42S (except a proprietary infrared port), which many already considered a decline in the series. However, it ran on just three LR44 batteries instead of pretty non-standard N-size cells, while supporting the same programming language. As we can see, 1980s and 1990s were the time when the line between (programmable) calculators and computers started to blur as much as it could, but again, HP Voyagers, in my opinion, were among the first to blur it. And the fact that we can play Bulls and Cows even on the 12C just proves it one more time. Since then, I have ported some other games to the 12C, like a dice simulator, a Yahtzee box simulator, a customizable variant of Nim, even the entire Mugwump/Hunt the Hurkle/Snark trilogy and a 3D variant of Hurkle called Depth Charge. But this particular game... It, so to speak, in a way, still makes me horny. --- Luxferre --- [1]: gopher://hoi.st:70/0/docs/own/hp-12c-soft.txt