Skip to content

Chapter 7

Time

Chapter 1 stated the game's rhythm in a paragraph and a box: a logic tick every fourth VBlank, a pass every second VBlank on a real PAL Amiga in a quiet scene. This chapter takes them apart. By its end you will know the game's three rhythms and what each is for; how the stick reaches the logic; why a pass, the round that draws a picture, also moves the game on; why the interleaving of passes and ticks is an input of the simulation, and what depends on it; how a film of a real Amiga measured it; how the port keeps time in a browser; and why one setting of the port rests on the eye alone.

Three rhythms

The first rhythm is the VBlank, the moment the display finishes a picture: 50 times a second on a PAL Amiga, 60 on an NTSC one. It is the beat the game counts its time in, and it reaches the program as an interrupt, whatever the program is doing at the time. The game counts it in a VBlank server: a routine the operating system calls at every VBlank. The game installs two, the sound effects engine's and, after it, its own, vblank_server, which counts the VBlanks, takes the stick's input and scrolls the message ticker.

The second is the logic tick, one step of the simulation. The game takes one for every input byte its server collects, and the server collects one every fourth VBlank, so the logic runs at a quarter of the VBlank's rate: 12.5 ticks a second on PAL. Chapter 2 gave the reason: the interrupt collects the bytes on time whatever the drawing costs, and so the game becomes a function of its inputs.

The third is the pass, one round of the inner loop, which draws one picture. Each pass first draws, in frame_update, a fixed sequence of about twenty routines, and then runs a tick for every byte waiting, in run_queued_ticks. A pass runs as often as the machine manages and never more than once a VBlank: it begins by waiting for the first VBlank since the last picture was handed over, because until then the screen it would draw into is still on view (chapter 2). On real hardware a pass usually takes longer than one VBlank, and how much longer depends on how much there is to draw and to compute.

The first rhythm is the machine's. The second is tied to it by the program, four VBlanks to a tick. The third is tied to nothing but the scene, and most of this chapter is about what follows.

One byte every fourth VBlank

The logic never touches the stick or the button. Everything the player's hands do reaches it through the input byte, and the VBlank server makes the byte. At every VBlank, unless the game is paused, the server first calls vblank_every_frame, which times the fire button, and then counts a divider down. Look at the last three instructions: subq.w takes one off vblank_divider, bgt.w jumps past the input sample while the divider is still above zero, and move.w sets it back to four.

; re/Wings.lst 0x011774-0x011780: vblank_server [asm], a part of 0x011754-0x011965
011774  4eac8206             jsr        -$7dfa(a4)                             ; -> vblank_every_frame
011778  536cc364             subq.w     #$1, -$3c9c(a4)                        ; vblank_divider
01177c  6e0000c4             bgt.w      $11842
011780  397c0004c364         move.w     #$4, -$3c9c(a4)                        ; vblank_divider

The divider begins at zero, so the first VBlank after the start takes an input sample, and so does every fourth after it. The routine read_joystick makes the byte: four bits for the stick's directions, forward, back, right and left, and two for the button, one for held and one for tapped. The directions are the stick's state as it stands at the input sample. A push that begins and ends between two input samples never reaches the game.

The button is treated differently, because it means two things: a tap drops the chosen weapon, a hold fires the guns. A tap can be shorter than the four VBlanks between two input samples, and a reading at the input sample alone would lose it. So vblank_every_frame watches the button at every VBlank. If the button comes up within ten VBlanks of a press, it sets a latch, a flag that keeps a brief event until it is read, for a tap; if the button is still down after ten, it sets a second latch, for a hold. The next input sample copies both latches into the byte and clears them, a tap winning over a hold, so that one byte never says both. The threshold counts VBlanks, not ticks: the timing runs at the VBlank's rate, and only its result waits for the input sample.

The byte then goes into the input queue: the list of up to six input bytes that the VBlank server fills and the ticks empty, one byte a tick. Here is the rest of the input sample. The server calls read_joystick, compares the queue's count with 6, and when six bytes are waiting calls input_queue_pop, which drops the oldest, before the new byte goes in at the end.

; re/Wings.lst 0x0117D4-0x0117FA: vblank_server [asm], a part of 0x011754-0x011965
loc_0117d4:
0117d4  4eac820c             jsr        -$7df4(a4)                             ; -> read_joystick
loc_0117d8:
0117d8  41ecc358             lea.l      -$3ca8(a4), a0                         ; input_queue
0117dc  322cc356             move.w     -$3caa(a4), d1                         ; input_queue_count
0117e0  b27c0006             cmp.w      #$6, d1
0117e4  64000008             bcc.w      $117ee
0117e8  5241                 addq.w     #$1, d1
0117ea  60000006             bra.w      $117f2
loc_0117ee:
0117ee  4ebaff24             jsr        $11714(pc)                             ; input_queue_pop
loc_0117f2:
0117f2  3941c356             move.w     d1, -$3caa(a4)                         ; input_queue_count
0117f6  5341                 subq.w     #$1, d1
0117f8  d241                 add.w      d1, d1
0117fa  31acc3681000         move.w     -$3c98(a4), (a0, d1.w)                 ; input_byte

The queue is what lets a slow pass keep the logic's pace. A pass that takes longer than four VBlanks can find two bytes waiting, and runs two ticks, and the flight goes on at the same speed, in fewer pictures. Input is lost only when the passes fall more than six input samples behind, the oldest bytes first, or when the game empties the queue on purpose, at a mission's start and after the load and save dialogs.

The tick follows the bytes

At the other end of the queue is run_queued_ticks, which the inner loop calls after each pass has drawn. It runs logic_tick for as long as the queue holds a byte, and logic_tick takes one byte off the front and hands it to the routines that steer, fire and choose. The assembly is on the left, the port's C on the right. Look at the loop, jsr logic_tick with tst.w of the queue's count and bgt.b back to the call, and at the while that is its port; the first lines belong to a demo, played back or recorded (chapter 17).

; re/Wings.lst 0x0114D8-0x01150E: run_queued_ticks [asm]
; run_queued_ticks   [asm]
;   asm. one logic tick per queued input byte
;   callers: main
run_queued_ticks:
0114d8  4a6cbd46             tst.w      -$42ba(a4)                             ; demo_bytes_owed
0114dc  67000008             beq.w      $114e6
0114e0  4eac81d6             jsr        -$7e2a(a4)                             ; -> wait_next_vblank
0114e4  60f2                 bra.b      $114d8
loc_0114e6:
0114e6  4a2ca558             tst.b      -$5aa8(a4)                             ; pause_flag
0114ea  6a000008             bpl.w      $114f4
0114ee  4e75                 rts
loc_0114f0:
0114f0  4ebafe94             jsr        $11386(pc)                             ; logic_tick
loc_0114f4:
0114f4  4a6cc356             tst.w      -$3caa(a4)                             ; input_queue_count
0114f8  6ef6                 bgt.b      $114f0
0114fa  426cbd46             clr.w      -$42ba(a4)                             ; demo_bytes_owed
0114fe  0c6c0000bd4e         cmpi.w     #$0, -$42b2(a4)                        ; demo_mode
011504  67000008             beq.w      $1150e
011508  397c0002bd46         move.w     #$2, -$42ba(a4)                        ; demo_bytes_owed
loc_01150e:
01150e  4e75                 rts
/* src/front.c, lines 735-751 */
/* orig 0x0114D8 run_queued_ticks - one tick per queued byte.  In a demo, played back or
 * recorded, it first waits VBlank by VBlank until vblank_server has taken the two bytes
 * 0x026D44 asks for, so a demo's pass has two ticks (re/notes/demo.md). */
static wof_co_t run_queued_ticks(void)
{
    wof_ctx_t *c = &wof_f.co_ticks;

    CO_BEGIN(c);
    while (wof_g.demo_bytes_owed)                                    /* 0x0114E0 */
        CO_CALL(c, &wof_f.co_vblank, wof_wait_next_vblank());
    if ((int8_t)wof_g.pause_flag < 0)
        CO_RETURN(c);
    while ((int16_t)wof_g.input_queue_count > 0)
        CO_CALL(c, &wof_f.co_tick, wof_logic_tick());
    wof_g.demo_bytes_owed = (int16_t)(wof_g.demo_mode != 0 ? 2 : 0);
    CO_END(c);
}

One tick for each byte, and one byte for every four VBlanks: the logic's rate is the input sample's, whatever the passes do, as long as they keep within the queue. A pass of two VBlanks makes two passes to a tick.

A pass is not only a picture

If a pass only drew, the number of VBlanks it takes would change what you see and nothing else. It does more. frame_update runs game logic once in every pass: the soldiers on an island move and die there, score is added, messages are queued for the ticker, objects are spawned, and the restart after a lost aircraft and the countdown at the end of a game advance. Nine routines among those it calls, directly or further down, draw random numbers. None of this is a fault the port could quietly put right. A faithful port translates what the code does (chapter 1): what the original does once a pass, the port does once a pass, and how many VBlanks a pass takes becomes part of what the port must reproduce.

What matters to the simulation is what crosses from the pass into the tick. To find it, we ran the headless original with every write and every read of memory tagged with the phase it was made in: a VBlank's server, the tick's routines, the pass's routines, or the main program outside them (chapter 6 named the instruments). A tool runs a mission script twice. The first run says which ranges of memory a pass writes; the second watches every read of exactly those ranges and says who reads them inside a tick. Over seven scripts, from a mission left alone on the carrier's deck to every life used up, a pass wrote a few hundred ranges, which join into blocks where they touch, and a tick read about a third of the blocks.

Each of those is a coupling: a piece of state that a pass writes and a logic tick reads, through which the number of VBlanks a pass takes reaches the simulation. Grouped by what they are:

A pass writes What the tick finds
a drawing copy of each object's position and frame, in the object's own record, and of the aircraft's height the copies, which it reads back
the byte of an object's record that says what the object is records the pass has freed or changed
the pass counter, which runs from 0 to 99, and a flag that a picture was drawn counts that the objects and the lift go by
a distance worked out while drawing the world a value the ground guns' sound uses
the pools of splashes, smoke and balloons, whose records it draws, counts down and frees, and claims for its smoke free records of the splashes' and smoke's pools, for the tick's splashes and the engine's smoke, and the balloons' records it steps
the soldiers' table the soldiers, who live in the pass, for the tick's shots to hit
the score and an island's count of soldiers a soldier who died, and scored, in a pass
the aircraft's oil and fuel what a gun's fire took from them in a pass
the clip rectangle and where the drawing routines draw the state it needs to draw itself

The table is a lower bound, since a coupling shows only where a script reaches it. Two of its rows, the soldier who scored and the gun's fire, came from scripts written after the seven, for the weapons; the scripts written for the ships found one more writer of the byte that says what an object is, a ship's shell that reaches the torpedoes in a pass. Chapter 8 tells of those scripts.

The last row is the surprise: the tick draws too. When the aircraft is lost in the sea, the player's update, which runs in every tick, calls the restart, and the restart clears the playfield and waits some twenty VBlanks, WaitTOF by WaitTOF, inside the tick. So the drawing state belongs to the pass's routines and the tick's alike, and VBlanks can go by in the middle of a tick.

The schedule is an input

So the number of VBlanks a pass takes is not only a matter of how smooth the picture looks. It sets how fast the soldiers run, how fast the countdown at the end of a game falls, how often the objects are animated, and everything else in the couplings. How passes and ticks interleave is an input of the simulation, as the input bytes and the entropy stream, the reproducible values that stand in for the beam, are inputs. Chapter 6 called the order of a run's VBlanks, passes and ticks its schedule.

The listing cannot say how many VBlanks a pass takes on a real Amiga. That is processor time: how long the 68000 needs for the drawing and the logic of one pass, which depends on the scene. The headless original has no model of the processor's cycles; it lets VBlanks happen only where the program waits, and at the start of each pass it delivers the VBlanks still owed since the last pass began, those spent waiting inside a tick counted (chapter 6). The setting is two, which the film of the next section measured.

Twelve VBlanks after a mission begins, in three rows: a pass every VBlank, every second and every third; in each row the same input samples, here at VBlanks 1, 5 and 9, and three ticks; under each row the schedule a run records.

One stretch of a mission at one, two and three VBlanks a pass, and under each row its record: V a VBlank, P a pass begun after it, T a tick. In every row the same three ticks take the same three bytes; only the passes between them differ. The middle row is the machine's in a quiet scene.

Is the table of couplings the only way the VBlanks a pass takes reach the tick? To find out, we ran the same mission script at all three settings and compared the game's state at equal tick numbers, byte by byte. The random numbers are such a way too: a pass draws them, so at another setting the tick would draw other values, and the comparison therefore sets the entropy stream to one constant value. It also checks first that the three runs fed the tick the same input bytes, since otherwise it would say nothing.

Every byte that differs must then fall into one of four classes: written by a pass; written by a VBlank server, since at equal tick numbers the three runs stand a VBlank or two apart; written in a tick by a routine that reads a coupling; or written by a routine such a reader calls. Anything else would be a finding.

Script Ticks Bytes that differ Left unexplained
level flight, nothing in the air 220 51 none
into the sea and through the restart 600 82 none
twenty bombs, which bring soldiers out 1,050 121 none

Of about 21,000 bytes of state, the hardest of the three runs differs in 121, and every one of them falls into a class. Everything else is identical at equal tick numbers, the map, the queue and the tick's own input bytes included. The tick is a function of the input bytes and the entropy stream alone, as long as the couplings are reproduced, and the VBlanks a pass takes change nothing but them.

The full comparison runs for minutes, so the suite holds a short one over a fourth, shorter script, the guns firing; "the pass rate" is the tools' name for the VBlanks a pass takes. Look at the demand on line 10 that the input bytes match, the bound on line 14, set above the largest count seen, and the demand on line 15 that nothing is left over:

# tests/test_passes.py, lines 91-106
def test_the_pass_rate_changes_only_what_the_pass_writes():
    """The control of re/notes/passes.md, over a shorter script: the same run at one, two and
    three VBlanks per pass, over an entropy stream of one constant value so that all three see
    the same stream.  The three have to feed the tick the same input bytes, or the comparison
    says nothing; then every byte that differs at the same tick number must have been written
    by a pass, by a VBlank server, or inside a tick by a routine that reads a range a pass
    wrote, or lies below one.  Whatever is left over is a finding, and there is none."""
    result = pass_observe.control(name='guns', ticks=55, rates=(1, 2, 3), verbose=False)
    assert result['inputs_match'], 'the three rates fed the tick different input bytes'
    assert len({result['counters'][rate][0] for rate in result['counters']}) == 3, \
        'the pass rates did not differ'
    assert result['differing'], 'nothing differs at all, so the control shows nothing'
    assert len(result['differing']) < 200, 'far more state depends on the pass rate than the note says'
    assert not result['leftover'], result['leftover']
    verdicts = {verdict for verdict, _, _ in result['rows']}
    assert 'written by a pass' in verdicts, verdicts

That is why the comparisons of chapter 8 hand the port the schedule as part of its input, and why the port must take as many VBlanks a pass as the machine does. A player would see the difference: on a machine whose passes fit into one VBlank, the figure's top row, the soldiers would run and the countdown fall twice as fast.

Two VBlanks a pass: the film

One could count the 68000's cycles in a cycle-exact emulator, but the machine itself was at hand, and a film of it answers on the hardware. So the project's owner filmed their PAL Amiga 500, its monitor fed over HDMI, with a phone at 240 frames a second: the story scroller, then the carrier's hold, its lift and the aircraft rolling along the deck. In the film one film frame is a 240th of a second, and one picture of the display, from one VBlank to the next, which we call a refresh here, lasts nearly five film frames.

The picture on the screen changes once a pass, when the pass's drawing is shown. So the time between two changes, counted in refreshes, is the number of VBlanks a pass takes. tools/film_rate.py measures it: it takes the average change of brightness between each film frame and the next in a chosen part of the game's picture, finds the film frames where that change jumps, and counts the intervals between them. A shaking hand does no harm, as two film frames are only about 4 milliseconds apart. The part chosen must change with every pass and leave the dashboard and reflections out: the band of sea below the ship worked, the strip under the hull in chapter 1's picture of the first mission.

A camera and a video connection could drop or merge refreshes, so the method was first tried on a picture whose rhythm is known without measuring it. The story scroller sets its pace with its own waits: each of its steps waits for the VBlank, WaitTOF by WaitTOF, and shows a new picture every second VBlank, so its pace is written in the program, not in processor time. The port's front end, held to the original's VBlank by VBlank, changes the scroller's picture every second VBlank in nearly every interval. On the film the scroller changed every 9 or 10 film frames, two refreshes: the chain from the Amiga to the camera resolves every change. That is the calibration.

Thirty film frames in a row with the VBlanks above them, one every 4.8 film frames; the film frames 0, 10, 20 and 29, where the picture changed, marked in gold, 10, 10 and 9 film frames apart.

How the film reads: a change every 9 or 10 film frames is a change every second VBlank. The strip is idealised; the film's intervals are counted by the tool.

In the quiet scene the sea band, too, changed every second refresh in most of the intervals, as the box counts them: a pass takes two VBlanks on the real machine there. The setting of the headless original and of the port is that measurement.

The figures

The film How long, how often
One film frame 1/240 s
One refresh on PAL, VBlank to VBlank 4.8 film frames
The story scroller, the calibration a change every 9 or 10 film frames: 2 refreshes
The sea band, quiet scene: intervals of 2 refreshes 85 of 120
The other intervals other multiples of a refresh, 10 of them at 1, 4 at 3

A busy scene, with many objects, soldiers and explosions in the air, was not filmed. There the original may need three VBlanks a pass, and the port would have to model the load to follow it. The port felt right in play, and the choice was made to leave it so, the busy scene and the fades unfilmed: the port keeps two.

How the port keeps time

A browser offers animation frames at the monitor's rate, not VBlanks. The port's shell, the JavaScript around the game, therefore keeps a clock of its own: it adds up the real time that passes and issues one VBlank of the core, the game compiled to WebAssembly, for every fiftieth of a second on PAL, and after each VBlank calls the core's pass entry once. What counts is the game's time, its VBlanks, not the monitor's pictures: a monitor of 144 Hz and one of 30 Hz alike get 50 VBlanks a second.

The core's VBlank entry is the port of vblank_server with vblank_every_frame, the divider, the input sample and the queue of six included. Its pass entry resumes the port's main program where it waits. Every wait of the front end, the screens before and between missions, is counted in whole VBlanks, one resume per VBlank: a Delay of the system one VBlank for each fiftieth of a second on PAL, a round of the music's fade four. So the port and the headless original stay together VBlank for VBlank. In play the program waits at a pass's start until the setting's two VBlanks have gone by, the headless original's rule, and a resume that finds it still waiting does nothing. VBlanks the tick spent waiting in the restart count towards the next pass, as in the original; chapter 22 tells how the port's code stops at such a wait and goes on at the next VBlank.

After a stall, while the page is on view but the browser could not run it, the clock replays at most 24 VBlanks: the six input samples the queue would have held; anything older the machine would have dropped too. A hidden page replays nothing. Its clock stops, and a mission comes back paused (chapter 23).

The program never asks which video standard it runs on. It reads none of the system's fields that tell a 50 Hz machine from a 60 Hz one. Everything is counted in VBlanks, so on a PAL machine everything runs at five sixths of the speed of the American machines the game was designed for. Only the music's timer and two short waits of the sound engine keep time of their own (chapters 2 and 18). For the port, PAL or NTSC is the rate of the shell's clock and of the core's sound clocks, switched together with the shape of the pixels behind the diagnostics overlay (chapter 23); PAL is the default.

The figures

The rates PAL NTSC
VBlanks a second 50 60
Input samples and logic ticks a second 12.5 15
A pass in a quiet scene every 2nd VBlank, filmed not filmed; the port keeps 2

The fade step

A fade carries a picture's colours from one table to another, most often from black or to black, in sixteen steps. Each step moves every colour a step of the way, with the original's own arithmetic, carries and all, which the oracle holds (chapter 5), and rebuilds the copper's list with the new colours. There is no wait in the loop at all. On the machine a step lasts as long as its arithmetic and its rebuild take the processor, and nothing in the program says how long that is. In the headless original, which has no clock between the program's waits, a fade takes no time at all.

In the port, as in the headless original, the game's time passes only where the program waits, so the port has to give the fade a duration of its own. It gives each step a number of VBlanks, a setting of the core, whose comment says why the listing cannot settle it:

/* src/fade.c, lines 18-20 */
/* PROVISIONAL (SPEC 10, point 6): how long one of the sixteen steps of a fade takes.  Read
 * the listing as one may, it cannot be settled there; a cycle-exact emulator can. */
static uint16_t fade_vblanks = 2;

The value 2 is the owner's eye: the fades of the title sequence's pictures were watched and found right, and no film was made of them. Of the port's two settings of time that are estimates, this is the one no film stands behind at all; the other rests on a film of a quiet scene. Here is where it is spent. Look at the sixteen steps of the outer loop and the inner loop that waits out fade_vblanks VBlanks after each:

/* src/fade.c, lines 184-201 */
/* The body of all four.  `target1` of 0 is the all-black table the fade_out routines
 * build on their stack; `pair` says whether the second viewport comes along. */
wof_co_t wof_fade(const uint16_t *target1, const uint16_t *target2, int pair)
{
    wof_ctx_t *c = &wof_f.co_fade;

    CO_BEGIN(c);
    fade_begin(target1, target2, pair);
    for (wof_f.fade_step = 0; wof_f.fade_step < 16; wof_f.fade_step++) {
        fade_apply(wof_f.fade_step);
        /* Each step rebuilds and installs the front view's copper list.  What survives of
         * that here is that vblank_flag is cleared, so a wait_vblank after a fade waits. */
        wof_view_show(wof_f.front_view);
        for (wof_f.fade_wait = 0; wof_f.fade_wait < fade_vblanks; wof_f.fade_wait++)
            CO_WAIT(c);
    }
    CO_END(c);
}

The comparisons set the fade step to zero; then the port's fades take no time either, and the two agree VBlank for VBlank through every fade. The music's fade is another wait, of the front end for the music player, which chapters 6 and 18 tell.

For the developer

The couplings and the comparison at three settings are tools/pass_observe.py: without arguments the table over the seven scripts, with --control --runs NAME --ticks N the comparison. The phase-tagged writes and reads are tools/headless_writes.py; the suite's tests, tests/test_passes.py. The film's tool, tools/film_rate.py, takes the film with --crop X0 Y0 X1 Y1 and needs ffmpeg. The shell's clock is web/clock.js; the core's VBlank entry, wof_vblank, is in src/input.c, the pass's start in wof_frame_update of src/world.c, the pass setting in src/core.c and the fade's in src/fade.c.

What comes next

The chapter in one sentence: the game's logic is a function of its input bytes, its random stream and its schedule, and of the three only the schedule had to be measured on the machine. Chapter 8 holds the port to the headless original at every tick and every pass. The headless original records its schedule as it runs, and the port's tests replay exactly that schedule through the core's VBlank and pass entries, as part of the run's input; the comparisons hold at one and three VBlanks a pass as they do at two. Chapter 8 also tells what of the original had to be ported at all, and how the port is known to be complete.

Further reading

The files named here are in the repository, github.com/sy2002/wof-wasm.

Outside the repository: the Amiga Hardware Reference Manual, for the VBlank's interrupt; the Amiga ROM Kernel Reference Manual: Exec, for the interrupt servers; and Glenn Fiedler's "Fix Your Timestep!", for the kind of fixed-step clock the shell keeps.