# Flight physics and energy beacons: first logic pass

2026-09-18. Addresses are offsets in the canonical unpacked 64 KiB image.
This identifies the core routines and verifies selected arithmetic against the
original machine code. It is not a complete decompilation of the flight callbacks.

Artifacts:

- [Selected disassembly](../analysis/flight-physics/selected-disassembly.asm)
- [Original-code probe results](../analysis/flight-physics/verification.json)
- [Reproducible probes and reference arithmetic](../tools/verify_flight_physics.py)

## What is worth doing next

The major static visual assets are accessible already. Remaining valuable asset
work includes playable Sound Images music/effects, scripted playback/composition
of the recovered animation frames, the gate and fountain transform commands, and
precise sprite-sheet/font layout metadata. Those are useful parallel milestones,
but need not precede game-logic recovery. The raw archive resources are preserved.

## Update path and state

`B0CE–B0D8` computes a timer difference (`[0BF0] - [0BF8]`) and stores it in the
self-modifying immediate at `7A2B`. `3DDC–3DF5` feeds that step to input handling
and writes two resulting control quantities at `7EBA` and `7ECF`.
`79BB–7A38` walks object lists, preserves old positions, loads this same step in
CX, and calls the function pointer at object offset `+66`.

The player object is at `SS:D986`. BP-relative object accesses default to SS;
plain absolute references use the code/data segment. Confusing these segments
would produce a superficially plausible but incorrect decompilation.

| Location | Current interpretation | Evidence |
| --- | --- | --- |
| Object `+04,+05,+06` | Fractional bytes for the three position components | Accumulated with carry by `8208`, `826E` |
| Object `+08,+0A,+0C` | Horizontal X, horizontal Y, vertical position words | Same integration routines; `8450` uses `+0C` for altitude |
| Object `+24,+26,+28` | Angular motion state | `8292–8308`, `8067`, `8394`; full units still to be documented |
| Object `+2A` | Heading angle | Indexes horizontal sine/cosine movement |
| Object `+2C` | Pitch angle | Vertical/horizontal projection and altitude control |
| Object `+2E` | Bank/roll angle | First steering channel, heading/pitch coupling |
| Object `+30` | Angular response parameter | `83D4`; populated by `BF53` |
| Object `+38,+3A,+3C` | Previous position words | `7A18–7A27` |
| Object `+3E` | Combined speed magnitude | `8254`: square both speed components, integer square root |
| Object `+40` | Vertical velocity component | `826E` |
| Object `+42` | Forward/horizontal velocity component | `8208` |
| Object `+66` | Current update callback | Dispatcher `7A2D` |

Position words plus fractional bytes form 24-bit accumulators. Horizontally,
256 position-word units represent one map cell; that corresponds to 2,048 native
model units. The vertical position is used directly against the beacon height
2,400. These are engine units, not calibrated metres or mph. Angles wrap in
16 bits; `angle >> 6` selects one of 1,024 sine samples.

## Flight callbacks

| Entry | Role |
| --- | --- |
| `7E7F` | Caero initial engine/reserve startup state |
| `7EB6` | Normal Caero flight update after callback transition |
| `8108` | Normal Skimma flight/shield update |
| `7E49` | Transitional callback initially used by configuration 3; can switch to `8108` |
| `D510` | Underground configuration callback; not traced in this pass |
| `7D9A` | Automatic landing callback; see flight-controls.md |

`7EAD` is a small but important state-machine trick: it pops its call's return
address into the object's callback pointer and jumps there. Thus the initial
Caero callback is not its steady-state physics function.

The Caero update combines steering, bank-related turning, altitude assistance,
forward-speed response, vertical response and energy accounting. These are
handcrafted integer dynamics, rather than a general rigid-body or aerodynamic
library. `8067` selects manual pitch behavior versus altitude assistance using
flag `7ED2`; `80A4` reads the desired-height term and current height. `80C4` adds
a speed-dependent correction. The vertical target calculation near `8004`
contains a default `-90` term, **patched from the craft definition at `BD16–BD19`**,
and an altitude-dependent term. It should not be promoted to a universal gravity
constant without completing the surrounding equations.

Skimma `8108` uses a response term derived from the square of
`max(0, 2047 - speed)`, so control response depends on speed. Its forward target
uses the selected speed parameter plus `speed >> 3`; the brake substitutes 130
for the selected parameter (`81C9–81E0`). The known speed selections are 248,
500 and, for the upgraded configuration, 640. These are internal target
parameters, not directly established road/air speeds.

### Shared integration, with checked rounding

`8208` updates forward velocity and horizontal position. `826E` updates vertical
velocity and height. In the tested bounded range, their velocity update is:

```text
delta = arithmetic_shift_right((target - old_velocity) * step, 16)
new_velocity = old_velocity + delta + (delta == 0 ? 1 : 0)
mid_velocity = old_velocity + arithmetic_shift_right(new_velocity-old_velocity, 1)
```

The forward target has already been multiplied by pitch cosine. Horizontal
integration forms a signed travel term from `mid_velocity * step`, then projects
it with heading sine/cosine into the two 24-bit position accumulators. Vertical
integration accumulates the high 24 bits of `2 * mid_velocity * step`.

The `CMP AX,1 / ADC AX,BX` sequence gives a surprising result: with old velocity
100 and target 100, the next velocity is **101**, not 100. A direct original-code
probe confirms this. Signed shifts also make opposite headings differ by one
fractional position unit in the recorded example. A faithful C++ version must
preserve these operations, carry behavior and wrap boundaries; ordinary float
interpolation would change them.

The angular helper family is `83B3–8411`: bounded angular error, response gain,
damping and midpoint-like integration. `7AD6–7C72` converts held keys and Ctrl
into smoothed input. These are identified but not yet independently decompiled
and verified to the same extent as the linear integration routines.

A further initialization dependency surfaced during verification: `9D6E–9D79`
sets up copying the first 256 sine words to `9C4A`, performed by `REP MOVSW` at
`03DC`. This extends the table for cosine lookups that cross its end, overwriting
startup code. The probe recreates that copy. Reading only the untouched unpacked
image gives incorrect fourth-quadrant physics—a useful warning for a full
static disassembly and reimplementation.

## Beacon selection: a regular lattice, not a proximity search

All **225 Delphi beacons** occupy the 15×15 lattice of map coordinates
`0, 9, 18, …, 126` on each axis. Startup `0396–03A9` generates a 256-byte coordinate
lookup table at world-segment `A800`. For in-city coordinates it selects the
nearest lattice coordinate from the integer cell index; its exact wrap behavior
is recorded in the JSON rather than replaced with an assumed modulo formula.

`8450` looks up one candidate cell using those two coordinate mappings. It does
**not** scan a list of towers or sum contributions from several towers. It returns
zero if the selected coordinates are outside the map or that cell's type is not
1. Map initialization `BB75–BB8B` gives these lattice type-1 cells state byte FF.
The second cell byte is therefore a beacon strength input, not just a universal
building-destruction flag.

### Distance law

Inputs to `8450` are DX/BX (horizontal position words), AL/AH (their fractional
bytes) and SI (height). Given the selected tower's column and row:

```text
rx = 16*(x_word - tower_column*256) + floor(x_fraction/16) - 2048
ry = 16*(y_word - tower_row*256)    + floor(y_fraction/16) - 2048
rz = 2*height - 4800

distance_term = floor((rx*rx + ry*ry + rz*rz) / 16384)
denominator = max(256, distance_term) + 1024
energy_value = floor(cell_state * 65793 / denominator)
```

This is the recovered arithmetic for normal in-map, nonnegative-height inputs.
The original is word arithmetic, including signed comparisons, not an unbounded
mathematical function. It has an explicit signed height cutoff: at **11,616**
and above it immediately returns zero. Horizontal and vertical terms correspond
to a centre at the cell midpoint and height **2,400**, matching the tower's light
disc. The minimum distance term makes a flat maximum-strength region near that
centre; farther away it is an inverse-squared-distance-style falloff with an
added denominator constant. It contains no line-of-sight/occlusion test.

Original-code examples for a full-strength (FF) beacon:

| Position | Returned energy value |
| --- | ---: |
| At beacon centre, height 2,400 | 13,107 |
| One cell horizontally away, same height | 8,191 |
| Two cells horizontally away, same height | 3,276 |
| Directly below it at height 0 | 6,904 |
| Directly above it at height 11,615 | 771 |
| Height 11,616 | 0 |

At the centre, state 128 gives 6,579; state zero gives zero. These are intermediate
engine values, not percentages or energy units per second.

## From beacon strength to recharge

`7F35` calls the beacon function. `7F38–7F3F` forces its result to zero unless
the engine flag is exactly 1. `4550` receives the indicator value
`floor(min(24, energy_value >> 8)/2)`, ranging from 0 to 12.

The following code uses multiple accumulators, rather than directly adding the
raw beacon result to a single fuel counter:

| Address | Role in this path |
| --- | --- |
| `7F8F` word | Intermediate energy buffer; filled here and drawn down earlier in the flight update |
| `7FA6` word | Accumulated energy reserve, capped at `CFFF`; high nibble becomes display value at `4551` |
| `8540` word / `8541` high byte | Boost reserve, capped here at `BFFF`; Enter spends 32 from the high byte |
| `7F0D` word / `7F0E` high byte | Active boost amount, consumed in the forward-target calculation |

For a normal small positive input step `t`, the Caero transforms CX to
`T = floor(257*t/2)` before this accounting. The initial boost gain is:

```text
if energy_value > 250:
    gain = floor((energy_value - 250) * T / 131072)
else:
    gain = 0
if gain != 0:
    gain += T >> 8
```

The low-strength portion (capped at 250) replenishes the intermediate buffer.
For energy values at least 256, up to `T >> 2` transfers from that buffer into
the main reserve, limited by available buffer and remaining capacity. `8529`
updates the two reserve caps and the energy display. The probe compares this
accounting against the original instructions for boundary strengths, engine off,
and both cheat states, starting from empty reserves.

The **Jason Brooke special** replaces the multiply at `7F7B` with `MOV DX,CX`.
On the `energy_value > 250` branch, the initial boost gain becomes `T >> 1`
instead of the distance-dependent product above. The engine gate and branch
threshold still apply in this path. This is a much more specific mechanism than
simply freezing or setting the reserve to infinity, and is consistent with the
observation of effectively unlimited boost recharges during play.

### Faults and partial output

Playtesting confirms faulty/disabled beacons later in the game. The code implements
**partial output during fades**, not just binary switching:

- The per-frame call at `3E27` runs `C510`. A configured run of map state bytes
  changes over a **256-timer-unit** interval.
- Script opcodes `12` and `13` select horizontal and vertical beacon runs through
  handlers `C470` and `C467`. They consume a packed lattice coordinate byte and
  a count. The cell-byte strides are 18 and 2,304 respectively: nine columns or
  nine rows in the expanded map. `C54C` installs the run and deadline.
- Script opcode `14`, handler `C1A0`, complements the fade-mode byte at `C526`.
  Initial mode FF is installed by `2DC2`.
- In dimming mode the updater caps each selected state byte at the descending
  value. Existing weaker/off beacons are not accidentally brightened.
- In restoration mode it assigns the ascending value, ending at FF. The updater
  then disables itself. This is one configured run at a time, not a separate
  independently timed fade controller for every beacon.
- Opcode `11`, handler `C442`, consumes the next coordinate from the mission header queue. Setup BD98 binds C449 to F004; it is not a random sequence.
- `C569` consumes that whole FF-terminated list and sets its beacons to zero, but its caller is the mission-exit path at 3ECB, **not initial setup**. The earlier initial-outage interpretation is corrected in [blackout mission analysis](blackout-missions.md).

The original horizontal-run setup and updater were executed for two beacons:

| Elapsed timer units | Dimming strength | Restoring strength |
| --- | ---: | ---: |
| 0 | 255 | 0 |
| 1 | 254 | 1 |
| 64 | 191 | 64 |
| 128 | 127 | 128 |
| 192 | 63 | 192 |
| 255 | 0 | 255 |
| 256 and later | 0 | 255 |

Since `8450` multiplies by that state byte, these intermediate values really
reduce charging capability; they are not merely a visual fade. The routine
itself has no source-cell write or drain caused by drawing charge.

These findings establish implemented scripted fade behavior. Exact mission
activation/timing and the full mission-script control flow are not yet decoded.
No persistent arbitrary intermediate-strength setting has been established.
The timer's real-seconds conversion remains open, so the interval is **not**
claimed to be 256 frames or a particular number of seconds.

## Verification and next steps

```sh
PYTHONPATH=/tmp/darker-python python3 tools/verify_flight_physics.py
```

Passed **209 beacon evaluations, 28 reserve-accounting cases, 200 vertical
integration cases, four cardinal horizontal integrations, the zero-error
rounding probe, and two beacon-fade timelines**. Startup coordinate lookup generation runs as original x86;
the sine extension reproduces its identified startup copy. Craft/map states are
controlled fixtures. This does not validate entire callbacks, collisions,
landing, long trajectories, or every arithmetic overflow case.

A productive next step is a small fixed-width C++ flight-state module driven by
recorded inputs, checked frame by frame against original-code execution. Finish
the angular helpers and normal Caero callback first, then the Skimma branch,
collision/damage and landing. Preserve exact sine values, startup mutations and
self-modifying state as explicit data fields. This provides a stronger foundation
for a full disassembly/decompilation than assigning names to a linear sweep of
mixed code, data and dynamically overwritten regions.


## Flight-speed calibration

We timed the starting Caero from beacon cell (0,9) to (0,126), holding
Enter with the Jason Brooke special enabled. Travel took approximately 15–16
seconds, with small boost gaps and steering inefficiencies. The straight-line
separation is 117 cells, or thirteen nine-cell beacon intervals. This gives
average route progress of **7.31–7.80 cells/second**, equivalent to about
**1.15–1.23 seconds per nine-cell interval**. It is not a demonstrated maximum
speed; initial velocity, precise height and endpoint crossing are unspecified.

The subsequent timer-setup probe supports approximately 500.075 source ticks
per second (see model-animation-and-states.md), so this run spans roughly
7,501–8,001 timer units. In the level, straight, continuous approximation to
8208, with transformed step approximately 128.5 times the source tick count,
this progress corresponds to an average internal forward velocity of roughly
3,800–4,100. Integer truncation, actual timestep distribution, non-level flight
and steering prevent treating that estimate as an exact recovered velocity.

The code explains why the cheat does not establish a single maximum speed:
it changes boost recharge, whereas each Enter activation sets the active boost
high byte to 38h; the flight callback consumes that boost and also feeds current
speed and beacon strength back into the next target (7F50–7F68). The craft-25
record's low byte at +0E is CFh, loaded and shifted left three by BD16–BD22 to
initialize the forward-response parameter to 1,656. These are distinct from
an observed cells-per-second value. No constants have been adjusted to fit the
observation, and a precise ideal traversal time is not claimed.

[Recorded observation](../analysis/flight-physics/user-flight-calibration.json).
A steady Skimma crossing, after settling at a chosen speed and approximately
constant height, would provide a cleaner independent check of position scaling
and integration. Record ordinary/upgraded craft, −/= or Enter setting, exact
beacon endpoints and whether timing begins after acceleration.


HUD terminology correction: the manual/gameplay mapping identifies display 4551 as the **Weapon Status Display charge meter**, and 454E/454F as the **Caero Power Storage Device** cell indicators/next-cell charge meter. References above to the reserve feeding 4551 describe the traced arithmetic and should not be read as identifying it with the stored boost cells. See [corrected HUD identities](hud-and-formatted-text.md).

## Streetlight source-type check

A further native 8450 probe tested all 256 cell type bytes at the selected lattice position, with full strength and the same centre/height fixture. Only type 1 produced charge; type 11 returned zero. [Results](../analysis/flight-physics/light-source-type-verification.json).

This rules out the small streetlight model as a direct source through this charging routine. It does not by itself explain the low meter reading near lamps: residual tower contribution, downstream buffers or another path would need a full moving-player trace. No undocumented streetlight contribution has been established.

## Follow-up: angular and impact verification

See [angular flight helpers and impact arithmetic](flight-angular-and-impact.md) for native differential checks of steering gain/damping, angle folding, shared object damage and the isolated building-state mutation. Complete flight callbacks and player collision response remain open.
