https://marnetto.net/2025/08/07/broderbund-stunts-appendixes

   Annali da Samarcanda

   Alberto Marnetto's Notebook
     __________________________________________________________________

        SuperSight: a graphical enhancement mod for Bro/derbund's Stunts

   The SuperSight series: Part I . Part II . Part III . Appendixes

Appendix 1: setting a breakpoint in the graphic options menu

   Here is how I managed to locate at which address DOSBox debug loads
   Stunts' menu code. My plan to find out was the following:
    1. Locate the code in Ghidra and note its address (x).
    2. Determine the offset between DOS and Ghidra address (y).
    3. Calculate x-y and set a breakpoint there

   Point 1 was, surprisingly, the hardest part. Ghidra does not allow to
   search for assembly instructions^1, so I needed to get the
   corresponding machine code. Also, anything with symbolic names was out
   of the question, since the symbols are different between Ghidra and the
   assembly. Anyway, the task seemed simple, since the menu code contains
   the instruction cmp ax, 9 which seemed special enough. To check its
   uniqueness, I grepped for it in the codebase. There was still a small
   trap: I needed to redirect the result of grep to a file and then cat
   it; skipping this step and displaying the results of grep directly on
   the screen resulted in just whitespace, because the console is confused
   by the crlf line endings.

src/restunts/asmorig$ grep --line-number -P 'cmp(\s)*ax,(\s)*9[[:space:]]' *asm
> /tmp/matches
src/restunts/asmorig$ cat /tmp/matches
seg004.asm:6123:    cmp     ax, 9
seg008.asm:4621:    cmp     ax, 9
seg008.asm:5358:    cmp     ax, 9

   Three hits, few enough. Then I needed to find corresponding the machine
   code. An easy thing, no? Just put the instruction in an assembler and
   read the result. There is a very convenient online assembler, made by
   Jonathan Salwan, which supports 16-bit x86. It informed me that the cmp
   instruction translates to 83 F8 09. I searched for the sequence in
   Ghidra and, astonishingly, it did not appear! I tried in vain other
   search parameters, but nothing came to light. How was this possible?
   The solution was revealed when I asked a second opinion to the most
   energy-inefficient assembler of the planet:

    ChatGPT decompiles the instruction to 3D 09 00 I mocked Copilot in my
   previous project, but this time I could not afford such luxury. Either
    LLMs have made huge progresses in the last two months, or ChatGPT is
    simply better than the Microsoft product in this domain, as we'll see
                                   later.

   I was a victim of Intel's CISC architecture, where even a trivial
   instruction such as cmp ax, 9 can be encoded in different ways. Sure
   enough, a search for 3D 09 00 yielded exactly three results, as
   expected from grep, and a quick examination brought me to the right
   point: 274B:2BE8.

   Now, phase 2: where is Ghidra's 274B:2BE8 address mapped to? A simple
   plan was to calculate the reverse: start the game in DOSBox, break it
   at a random location, note down the instructions being executed, look
   them up in Ghidra. Here I was lucky: stopping the game in the main menu
   I found instructions that are also in Ghidra's 274B segment, and in
   DOSBox are loaded in segment 18F0:

               ChatGPT decompiles the instruction to 3D 09 00

   Recapitulating:
     * The code for the menu appears in Ghidra at 274B:2BE8
     * Ghidra's 274B segment maps to DOSBox's 18F0

   To stop the game in the menu, I set a breakpoint at 18F0:2BE8. Mission
   complete: The breakpoint was hit as soon as I selected an option.

    [stunts--dosbox-menu.png] At the breakpoint, the register AX holds 1,
         consistent with me choosing the second option from the top.

   The data segment register is set at 2D1C, so the instruction at 2BED
   copies the detail level into 2D1C:018A, and all the other variables in
   the data segment are now easy to find. Granted, I could have spared the
   effort by noticing that the data segment pointer never changes (the
   MODEL MEDIUM in the assembly ensures that), but it was a good learning
   exercise.

Appendix 2: the civilized alternative to printf

   While I was indagating the resource usage of the graphical primitives I
   soon found the need to have a pleasant way to print debug information
   on the screen. Fortunately, Restunts already identified and labeled the
   function that shows the elapsed time during the race. It's called
   intro_draw_text and is very easy to understand: just pass the string to
   print, the coordinates and the color, and the text appears in the game
   screen in a relatively pleasant typeface.

   The only non-trivial task, then, was to setup a key to toggle the
   functionality. Since the function recognizing the hotkeys was not
   ported to C, I had to change the .asm files. I wanted to fiddle as
   little as possible there, and seven lines proved sufficient: with my
   patch, pressing the F5 key would make the assembly routine
   handle_ingame_kb_shortcuts toggle a bit in the global data segment.
   Such bit would be picked up by the C function update_frame to determine
   whether to display the debug information.

   [stunts--debug-info-workflow.png] The dataflow of the "activate debug"
                    bit, from the keypress to its effect.

   In the beginning I only printed the number of discarded tiles, but I
   quickly expanded the string to include the number and size of the
   graphical primitives and, near the end, whether the "reveal illusions"
   mode was active.

    The debug info in a scenery-rich spot. The track is Rayskate by "Zak
     McKracken", founder of the ZakStunts tournament. ZakStunts is still
   running today, 24 years after its foundation, and last month it reached
          the highest number of active participants in its history.

   The video shows how the debug statistics are displayed in the current
   release of SuperSight. The message is white if all the foreseen 110
   tiles around the car are rendered with maximum detail, yellow if the
   detail or the tiles have been reduced to avoid overstepping the size of
   the dedicated buffers. Note that some frames exceed the limits of the
   original game (400 polygons, 10400 memory bytes), thanks to the
   extension of the polygon buffer implemented in the later versions of
   the mod, as told in part III.

   Only much later I discovered I had inadvertently eschewed disaster. The
   successful compilation of Restunts demands that all the various
   segments making up the assembly code start on a paragraph boundary,
   i.e. their starting address must be a multiple of 16 (more in-depth
   explanations have been given by llm in the Stunts Forum, e.g. here).
   The need to keep alignment usually requires every alteration in the
   assembly functions to be padded with an appropriate number of nop
   instructions so that its "modulo 16 length" stays unchanged. When I
   first introduced the "print debug" bit, however, I was blissfully
   unaware of this and just shifted the existing code around with the
   gracefulness of an elephant in a china shop. But it must have been my
   lucky day because my changes did not corrupt the executable: apparently
   the instructions I inserted added exactly 16 extra bytes, so that
   nothing was broken.

Appendix 3: undoing Restunts

   After the publication of Restunts v1.0, some users noticed some
   discrepancies with respect to the original game. In particular,
     * some replays would de-sync
     * the game would terminate with an out-of-memory message when
       selecting particularly demanding cars

   I soon discovered that both these issues were already present in the
   master branch of Restunts, which was a relief - this time I was not
   guilty. However, I had assumed that the C functions of Restunts were a
   verified perfect equivalent of the original assembly, and these
   incidents proved it was not the case.

    [stunts--restunts-unsupported-comment.png] To be honest, I could have
       noticed the limitations of the rewrite already when reading the
      rendering function. Line 2297 is rarely triggered since not many
   objects use primitive type 5, but it leads to a crash if one e.g. tries
              to drive the user-made car Caterham Super Seven.

   Finding and fixing all possible errors in Restunts was an impossible
   task, but I had a simpler solution: undo most of the work done by the
   Restunts contributors. After all, the only C function I needed to
   modify was update_frame, all the others could be ditched in favour of
   the original disassembled machine code.

   So I did, and the following is the result (git diff master supersight
   --stat):
 src/restunts/c/externs.h                     |   64 +
 src/restunts/c/fileio.c                      | 1082 ------
 src/restunts/c/fileio.h                      |   56 -
 src/restunts/c/frame.c                       | 2655 ++++++++------
 src/restunts/c/heapsort.c                    |   70 -
 src/restunts/c/keyboard.c                    |  233 --
 src/restunts/c/keyboard.h                    |   18 -
 src/restunts/c/makefile                      |   67 +-
 src/restunts/c/math.c                        | 1102 ------
 src/restunts/c/math.h                        |    2 +-
 src/restunts/c/memmgr.c                      |  667 ----
 src/restunts/c/memmgr.h                      |   43 -
 src/restunts/c/restunts.c                    | 1718 ---------
 src/restunts/c/shape2d.c                     |  648 ----
 src/restunts/c/shape2d.h                     |   87 -
 src/restunts/c/shape3d.c                     | 5067 --------------------------
 src/restunts/c/shape3d.h                     |   49 -
 src/restunts/c/state.c                       |  317 --
 src/restunts/c/statecar.c                    |  841 -----
 src/restunts/c/statecrs.c                    |  321 --
 src/restunts/c/stateply.c                    | 3357 -----------------

   Most of the C files have been erased. The only survivors are frame.c,
   which contains the tile-rendering routine, and some auxiliary files.
   Adjusting the makefile to account for that was quick and easy.

   After performing this operation all the discrepancies between
   SuperSight and the vanilla game disappeared. For my personal curiosity,
   I still tried to find out where the differences lie. Analysing the
   replay desync was too complex: it would require understanding Stunts'
   physics model which remains terra incognita to this day. But the memory
   exhaustion problem was interesting.

   Stunts allocates part of its resources in the data segment and part in
   a dynamically allocated heap. Interestingly, the C rewrite had changed
   the memory allocator for the latter: while the original game employs
   the unusual^2 "Allocate memory" service of DOS (int 21h, AH=48h),
   Restunts calls instead the malloc function provided by the C runtime.
   Apparently the latter function was not able to deliver as much memory
   as the OS interrupt.

   After obtaining a block of memory, Stunts has an internal allocator
   that reserves chunks of it for various resources. I did not decode the
   whole algorithm, but I instrumented the function to get a glimpse at
   how the memory occupancy evolved. Since each chunk is associated with a
   resource name, the resulting picture is very informative. Here a diff
   comparing two runs, one featuring a Countach-Countach race, the other a
   Countach-Diablo duel.

   [stunts--heap-allocation.png] Unpaid ad: this diff program is P4 Merge,
              my favourite tool for conflict resolution in git.

   The first two numbers after each entry represent offset and size of
   each resource. The meaning of the third number is unknown ("resunk")
   but I suspect that a value of 0 marks the entry as deleted. The
   allocator seems happy to shift resources around, and the functions
   wishing to get a resource can probably request its current address by
   passing the name as key.

   I think that the entries start at about 18K because the previous part
   is taken by other things like the buffer for the 3D primitives, which
   prompted me to be prudent when sizing it. Apart for that, one can see
   how the run on the right has to additionally allocate the 3D shape of
   the Diablo (stDIA3.3sh), and curiously this has cascade effects on the
   position of the other resources. Notice how some entries are allocated
   at the end of the chunk. Some resources seem to overlap, but when that
   happens one of those has a resunk value of 0. Maybe it was erased and
   moved in another place (notice the duplicate names) when a previous
   element had to be enlarged? There is still a lot to discover in this
   game. As for me, I am for now happy with what I found out during my
   project, and will leave the rest to future explorers!
     __________________________________________________________________

    1. Actually, Ghidra does allow to search for assembly instructions,
       but at the time I was unable to get the functionality to work. I
       made so many mistakes in this project that I keep finding them even
       several months after its conclusion. &#8617;
    2. In modern programming, asking the OS for more memory is normal. Not
       so in DOS: this OS is happy to give the user the whole amount of
       RAM at program start if the user wants. &#8617;
     __________________________________________________________________

   No GitHub account? You can also send me your comment by mail at
   [email protected]
