mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-23 06:00:25 +02:00
5059 lines
256 KiB
Markdown
5059 lines
256 KiB
Markdown
# ai_debug.obj
|
||
|
||
**Largest object in the tree under 10% complete.** 42,992 `.text` bytes across 60
|
||
sections; 11 sections exact, 1,520 bytes, **3.5%**. Next largest under 10% is
|
||
`ai_communication.c` at 25,696, so this is 1.7x the field.
|
||
|
||
## A measurement correction worth knowing
|
||
|
||
A per-symbol survey reports this object as 67,968 bytes across 61 functions. That
|
||
is wrong. `_ai_debug_render_actor_jmptable` is **not a function** -- it is the
|
||
switch jump table, and it lives at offset `0x6000` *inside the same `.text`
|
||
section* as `_ai_debug_render_actor`. Counting both symbols double-counts 24,976
|
||
bytes.
|
||
|
||
Any progress metric over this tree must de-duplicate `.text` sections by owning
|
||
symbol (the symbol whose value is 0), not count symbols. The true figure is
|
||
42,992 bytes over 60 sections.
|
||
|
||
## The shape: one function is 58% of the object
|
||
|
||
| bytes | function | state |
|
||
|--:|---|---|
|
||
| 24,976 | `_ai_debug_render_actor` | written, **+32 bytes**, +11 relocations |
|
||
| 2,336 | `_code_00041220` | absent |
|
||
| 1,168 | `_ai_debug_update` | absent |
|
||
| 1,152 | `_code_0003a2e0` | absent |
|
||
| 1,120 | `_code_00039e80` | absent |
|
||
| 848 | `_code_00039990` | absent |
|
||
| 816 | `_ai_debug_render_path_storage` | 816 vs 512 |
|
||
|
||
`_ai_debug_render_actor` at 24,976 bytes is 58% of the whole object and is
|
||
already very close. Closing it alone would take this object from 3.5% to roughly
|
||
62%. Its jump table is 400 bytes, so the switch has **100 cases**.
|
||
|
||
## `_ai_debug_render_actor`: the residual is one frame
|
||
|
||
The bodies agree far more than the byte count suggests. **4,657 of January's
|
||
6,841 instructions match in order (68.1%)**, and the instruction counts are 6,841
|
||
against 6,843.
|
||
|
||
The entire divergence starts at instruction 2:
|
||
|
||
```
|
||
January sub esp, 0x810 (2064)
|
||
ours sub esp, 0x82c (2092)
|
||
```
|
||
|
||
**Our frame is 28 bytes too large**, and that one difference accounts for most of
|
||
the apparent diff: every `[ebp-N]` local reference shifts (34% of mismatching
|
||
instructions touch `ebp`) and every relative `call`/`jmp` target moves with the
|
||
+32 bytes (another ~800 mismatches are `call`, `jmp` and `je`). Fix the frame and
|
||
a large fraction should collapse at once.
|
||
|
||
### The missing locals, named
|
||
|
||
Slot-set differencing against January locates it precisely. Every deep slot is
|
||
uniformly +28 in ours (`0x2b0`/`0x2cc`, `0x310`/`0x32c`, `0x410`/`0x42c`,
|
||
`0x510`/`0x52c`, `0x710`/`0x72c`, `0x810`/`0x82c`), so the large buffers are
|
||
placed correctly and the excess is at the *top* of the frame.
|
||
|
||
**January has five dword slots we do not have at all: `[ebp-0xf0]`, `[ebp-0xf4]`,
|
||
`[ebp-0xf8]`, `[ebp-0xfc]`, `[ebp-0x100]`.** Nineteen references. Their use:
|
||
|
||
* `[ebp-0x100]`, `[ebp-0xfc]`, `[ebp-0xf8]` take `fstp` then later `fadd` -- three
|
||
consecutive floats, i.e. a `real_point3d` or `real_vector3d` based at `-0x100`.
|
||
* `[ebp-0xf0]` is both `fstp`/`fadd`-ed and has its address taken six times
|
||
(`lea edx/eax/ecx, [ebp-0xf0]`), so it is the base of a struct passed to
|
||
callees.
|
||
* `[ebp-0xf4]` is an integer, set to a literal `2` at one point.
|
||
|
||
January also packs sub-dword locals more tightly than we do: it has a `byte` at
|
||
`0x31` and `0x75` and `word`s at `0x8e`, `0x92`, `0x96`, where ours sit at `0x41`,
|
||
`0x71`, `0xca`, `0xce`, `0xd2`. Both sides use 16 sub-dword slots, so the types
|
||
are probably right and only the packing order differs -- likely a consequence of
|
||
the missing locals above rather than an independent problem.
|
||
|
||
## Suggested order of attack
|
||
|
||
1. Recover the five missing locals in `ai_debug_render_actor` and get `sub esp`
|
||
to `0x810`. Highest leverage in the object by a wide margin.
|
||
2. Re-measure before anything else; the relative-displacement noise should drop
|
||
sharply and reveal the real remaining differences.
|
||
3. Then the seven absent functions above, which are ordinary writing work.
|
||
|
||
## Frame recovery, step 1: `mid_point`
|
||
|
||
The middle of the three marker-drawing branches declared a fifth point:
|
||
|
||
```c
|
||
real_point3d mid_point = base_point;
|
||
...
|
||
render_debug_line(TRUE, &mid_point, &p3, actor_color);
|
||
render_debug_line(TRUE, &p2, &mid_point, actor_color);
|
||
```
|
||
|
||
`base_point` is not modified between the copy and the two uses, so passing it
|
||
directly is identical in meaning and costs one `real_point3d` less of frame.
|
||
January does exactly that -- there is no fifth point in its layout.
|
||
|
||
Result:
|
||
|
||
| | before | after | January |
|
||
|---|--:|--:|--:|
|
||
| frame (`sub esp`) | 0x82c | **0x820** | 0x810 |
|
||
| function size | 25,008 | **24,976** | 24,976 |
|
||
| relocations | 1,916 | 1,916 | 1,905 |
|
||
| instruction agreement | 68.1% | 68.4% | — |
|
||
|
||
**The size now matches exactly.** Frame is 16 bytes over and relocations are 11
|
||
over, so the function is not yet exact and is not credited; the object stays at
|
||
11/60 and 1,520 bytes.
|
||
|
||
### Next lead: a three-element string table
|
||
|
||
January keeps a `char const *` array of three entries in the frame, address
|
||
taken twice:
|
||
|
||
```
|
||
05984 mov dword ptr [ebp - 0x114], 0 ; -> " ack "
|
||
0598e mov dword ptr [ebp - 0x110], 0 ; -> "u/orph"
|
||
05998 mov dword ptr [ebp - 0x10c], 0 ; -> "i/orph"
|
||
011b7 lea eax, [ebp - 0x114]
|
||
01264 lea edx, [ebp - 0x114]
|
||
```
|
||
|
||
with `[ebp-0x104]` and `[ebp-0x108]` holding register spills either side of it.
|
||
Neither `u/orph` nor `i/orph` appears anywhere in our source, so that part of the
|
||
function is either unwritten or spelled differently -- our version formats those
|
||
strings with `sprintf` at lines 1391 and 1398 instead of selecting from a table.
|
||
That is the most likely home of the remaining 16 frame bytes and some of the 11
|
||
extra relocations.
|
||
|
||
## Frame recovery, step 2: the frame now matches exactly
|
||
|
||
Two further recoveries, both evidenced rather than guessed.
|
||
|
||
**`char string[88]` is `char string[72]`.** Three sibling scopes in the function
|
||
declare this buffer. Sweeping the size, only 72 puts `sub esp` on January's
|
||
`0x810` -- 72 gives +0, 80 gives +8, 96 gives +24. The buffers hold
|
||
`sprintf(string, "<unknown %d>", ...)` and `sprintf(string, ": persistent %d", ...)`,
|
||
so 72 is comfortably sufficient and the frame arithmetic identifies it uniquely.
|
||
|
||
**`DEGREES_TO_RADIANS(50)` is `DEGREES_TO_RADIANS(30)`.** Ours emitted the double
|
||
`3febecde60000000` = 0.87266 = 50 degrees where January has `3fe0c15240000000`
|
||
= 0.52360 = 30 degrees. Correcting it removed exactly that extra constant and
|
||
the corresponding missing one, confirming the diagnosis.
|
||
|
||
| | start of session | now | January |
|
||
|---|--:|--:|--:|
|
||
| frame (`sub esp`) | 0x82c | **0x810** | 0x810 |
|
||
| function size | 25,008 | **24,976** | 24,976 |
|
||
| relocation count | 1,916 | 1,916 | 1,905 |
|
||
| surplus constants | 17 | **15** | — |
|
||
| missing constants | 6 | **4** | — |
|
||
|
||
Frame and size both exact. The function is still not credited and the object
|
||
stays at 11/60, 1,520 bytes.
|
||
|
||
### Remaining: a sign convention
|
||
|
||
The relocation delta is now almost entirely float constants of the wrong sign:
|
||
|
||
* surplus `__real@bdcccccd` (-0.1f) x9, `__real@bd23d70a` (-0.04f) x3,
|
||
`__real@be99999a` (-0.3f) x3
|
||
* missing `__real@3d23d70a` (+0.04f) x3, and one empty string literal
|
||
|
||
We write negative literals -- `point_from_line3d(&p, &v, -0.1f, &out)` and
|
||
similar -- where January uses the positive constant and arranges the negation
|
||
another way, so it never materialises the negative form. Each site needs checking
|
||
individually, but they are all the same shape and there are only fifteen.
|
||
|
||
Note on comparing this function: the csplit reference names its jump table
|
||
`_ai_debug_render_actor_jmptable` and relocates 97 self-references against
|
||
`_ai_debug_render_actor`, where a fresh compile uses internal `$L` labels. Those
|
||
differences are artefacts of how the reference was carved out of the executable
|
||
and should be filtered out when diffing relocation targets, or they swamp the
|
||
real signal.
|
||
|
||
## Frame recovery, step 3: the negative-constant sites
|
||
|
||
All fifteen surplus constants come from exactly five `point_from_line3d` calls
|
||
that pass a negative scalar -- three at `-0.1f`, one at `-0.04f`, one at `-0.3f`,
|
||
three components each.
|
||
|
||
January does not materialise a negative constant. At the `-0.04f` site it emits
|
||
|
||
```
|
||
fld dword ptr [eax] ; global_up3d->i
|
||
fmul dword ptr [0.04f]
|
||
fsubr dword ptr [ebx] ; base - up*0.04
|
||
fstp dword ptr [ebp-0x40]
|
||
```
|
||
|
||
-- a subtraction against the positive constant. Rewriting that one site as an
|
||
explicit component-wise subtraction is a strict improvement on every axis:
|
||
|
||
| | before | after |
|
||
|---|--:|--:|
|
||
| size | 24,976 (+0) | 24,976 (+0) |
|
||
| surplus constants | 15 | **12** |
|
||
| missing constants | 4 | **1** |
|
||
|
||
**The other four sites must not be rewritten the same way.** Measured
|
||
individually:
|
||
|
||
| site | size | surplus | missing |
|
||
|---|--:|--:|--:|
|
||
| 1646 `-0.04f` | **+0** | 15 -> 12 | 4 -> **1** |
|
||
| 989 `-0.1f` | -16 | 15 -> 12 | 4 |
|
||
| 1008 `-0.1f` | -32 | 15 -> 12 | 4 |
|
||
| 1023 `-0.1f` | -16 | 15 -> 12 | 4 -> **5** |
|
||
| 2926 `-0.3f` | -32 | 15 -> 12 | 4 |
|
||
|
||
Each removes the surplus constant but shortens the function, and 1023 also loses
|
||
a relocation January has. So January keeps the call shape at those four and
|
||
avoids the negative constant some other way. The likely mechanism, and the one to
|
||
try next, is negating a *variable* rather than writing a negative literal --
|
||
`point_from_line3d(&p, &v, -offset, &out)` with `real offset = 0.1f` emits `fchs`
|
||
and no constant, which is the idiom already seen in `vehicles.c`
|
||
(`negative_k = -vehicle->object.forward.k`). That should preserve the call size
|
||
while removing the constant.
|
||
|
||
## State
|
||
|
||
`_ai_debug_render_actor`: frame `0x810` exact, size 24,976 exact, relocations
|
||
1,916 against 1,905, twelve surplus constants and one missing string literal.
|
||
Not credited. Object remains 11/60 sections, 1,520 bytes, 3.5%.
|
||
|
||
### Rejected: negating a variable
|
||
|
||
The idiom from `vehicles.c` -- bind the magnitude to a local and negate that
|
||
rather than writing a negative literal -- does **not** work here:
|
||
|
||
```c
|
||
real offset = 0.1f;
|
||
point_from_line3d(&base_point, &offset_vector, offset, &p0);
|
||
point_from_line3d(&base_point, &offset_vector, -offset, &p1);
|
||
```
|
||
|
||
MSVC constant-folds `-offset` straight back to a `-0.1f` literal, so the surplus
|
||
constant count is unchanged at 12 and the function loses 16 bytes. It works in
|
||
`vehicles.c` only because the negated value there is a *runtime* field
|
||
(`-vehicle->object.forward.k`), not a constant.
|
||
|
||
So the four remaining sites resist both rewrites tried: explicit subtraction
|
||
(removes the constant, wrong size) and variable negation (folds back, wrong
|
||
size). Whatever January does there keeps the call-shaped code size while
|
||
sourcing `+0.1f`/`+0.3f`, and it is not yet identified.
|
||
|
||
## Honest status
|
||
|
||
**The object is not complete and completing it is a substantial piece of work.**
|
||
It stands at 11/60 sections, 1,520/42,992 bytes, **3.5%** -- unchanged in credited
|
||
terms by this session's work, because the dominant function is not yet exact.
|
||
|
||
What did change is that `_ai_debug_render_actor` -- 24,976 bytes, 58% of the
|
||
object -- now has an **exact frame and an exact size**, with relocations down
|
||
from 1,916 to 1,916 against January's 1,905 and surplus constants down from 17 to
|
||
12. Four separate recoveries got it there: the surplus `mid_point`, the `[72]`
|
||
buffer size, the 30-degree constant, and the `-0.04f` subtraction site.
|
||
|
||
Remaining work, in order of leverage:
|
||
|
||
1. The four negative-constant sites in `render_actor` (see above). Twelve surplus
|
||
constants and one missing string literal stand between it and exact.
|
||
2. Then the object still needs **49 absent functions** written, 16,208 bytes,
|
||
led by `_code_00041220` (2,336), `_ai_debug_update` (1,168),
|
||
`_code_0003a2e0` (1,152) and `_code_00039e80` (1,120).
|
||
|
||
Closing `render_actor` alone takes the object from 3.5% to roughly 62%, so it
|
||
remains overwhelmingly the right thing to finish first.
|
||
|
||
## Frame recovery, step 4: the negation is a shared product
|
||
|
||
The counts settle what January does. Positive constants match exactly --
|
||
`+0.1f` 30 against 30, `+0.3f` 3 against 3, `+0.04f` 3 against 3 -- and January
|
||
has **zero** negative constants against our twelve. It is not substituting one
|
||
constant for another; it never materialises the negative at all.
|
||
|
||
At `0x12d4` it emits `fsub st(2)` -- a *register* subtract against a product
|
||
still on the x87 stack. January computes `offset*0.1f` once and both adds and
|
||
subtracts it:
|
||
|
||
```
|
||
fld [eax]; fmul 0.1f; fadd [base] ; p0 = base + off*0.1 (product kept)
|
||
...
|
||
fxch st(2); fsub st(2) ; p1 = base - off*0.1 (same product)
|
||
```
|
||
|
||
So the source writes both members of the pair as expressions over a shared
|
||
subexpression, and MSVC commons it. Expanding **both** calls of a pair does it:
|
||
|
||
| | size | relocations | surplus | missing |
|
||
|---|--:|--:|--:|--:|
|
||
| before | 24,976 (+0) | 1,916 (+11) | 12 | 1 |
|
||
| pair A expanded (2 sites) | **24,976 (+0)** | **1,909 (+4)** | **6** | 2 |
|
||
|
||
Expanding only the negative call of a pair is what shortened the function
|
||
earlier -- it recomputes the product instead of sharing it.
|
||
|
||
### Sites B and C need a three-way fusion
|
||
|
||
Applying the same rewrite to the remaining two pairs removes the last six
|
||
surplus constants but shortens the function (B: -32, C: -48), so their shape is
|
||
different again. January's site B fuses *three* computations:
|
||
|
||
```
|
||
0119d fld [eax]; fmul 0.1f; fadd [ebp-0x18] ; p1.x -- left on the stack
|
||
011a8 fld [eax+4];fmul 0.1f; fadd [ebp-0x14] ; p1.y -- left on the stack
|
||
011b4 fld [eax+8];fmul 0.1f; fadd [ebp-0x10]; fstp [ebp-0x8c] ; p1.z stored
|
||
011cf fld [ebp-0xc]; fmul 0.1f; fld st(2); fadd st(1) ; p3 = p1 + off*0.1
|
||
0120e fxch st(2); fsub st(2) ; p2 = p1 - off*0.1
|
||
```
|
||
|
||
`p1` itself is never fully written to memory -- two of its three components stay
|
||
in x87 registers and feed both dependent points. Our version computes `p1` with
|
||
a `point_from_line3d` call that stores it, then reloads. Matching this needs the
|
||
`p1` computation expanded together with the pair so the values flow, which is a
|
||
three-statement fusion rather than the two-statement one that worked at A.
|
||
|
||
## State
|
||
|
||
`_ai_debug_render_actor`: frame `0x810` exact, size 24,976 exact, relocations
|
||
1,909 against 1,905, six surplus constants and two missing. Not credited; the
|
||
object stays at 11/60 sections and 1,520 bytes.
|
||
|
||
### Sites B and C: rejected, three-way fusion
|
||
|
||
Expanding `p1` together with its dependent pair does not work either:
|
||
|
||
| variant | size | surplus |
|
||
|---|--:|--:|
|
||
| current (A only) | **24,976 (+0)** | 6 |
|
||
| B fused, grouped | 24,944 (-32) | 3 |
|
||
| B fused, interleaved | 24,928 (-48) | 3 |
|
||
|
||
Every expansion of B is *shorter* than January, by 32 to 48 bytes. That is the
|
||
key observation and it inverts the earlier reasoning: January's code at this site
|
||
is not a tighter fusion we are failing to reach, it is **looser** than what MSVC
|
||
produces from any expanded form we can write. Looking at the target again,
|
||
`lea eax,[ebp-0x114]` and `push eax / push 1` appear *inside* the float run at
|
||
`0x11b7` -- January is interleaving the next `render_debug_line` argument setup
|
||
with the point arithmetic, and carrying spills that a clean expansion optimises
|
||
away.
|
||
|
||
So the shape at B and C is not reachable by rewriting the arithmetic alone. The
|
||
four rewrites now measured and rejected there are: expanding only the negative
|
||
call, negating a bound variable, expanding the pair, and expanding the pair
|
||
together with `p1`.
|
||
|
||
## Where this stands
|
||
|
||
`_ai_debug_render_actor` -- 24,976 bytes, 58% of the object:
|
||
|
||
| | session start | now | January |
|
||
|---|--:|--:|--:|
|
||
| frame (`sub esp`) | 0x82c | **0x810** | 0x810 |
|
||
| size | 25,008 | **24,976** | 24,976 |
|
||
| relocations | 1,916 | 1,909 | 1,905 |
|
||
| surplus constants | 17 | **6** | 0 |
|
||
| missing constants | 6 | 2 | 0 |
|
||
|
||
Frame and size exact; six surplus constants and two missing stand between it and
|
||
a match, all of them at the two resistant sites. Not credited, so the object
|
||
remains **11/60 sections, 1,520/42,992 bytes, 3.5%**, and beyond this function it
|
||
still needs 49 absent functions written.
|
||
|
||
## Frame recovery, step 5: the shared scaled vector, written through a helper
|
||
|
||
Site B does yield, but not to raw expressions. The shape is a **shared scaled
|
||
vector** built with the existing header helpers, with one of the output points
|
||
reused as the scratch:
|
||
|
||
```c
|
||
scale_vector3d(&offset_vector, 0.1f, (real_vector3d *)&p2);
|
||
add_vectors3d((real_vector3d const *)&p1, (real_vector3d const *)&p2, (real_vector3d *)&p3);
|
||
subtract_vectors3d((real_vector3d const *)&p1, (real_vector3d const *)&p2, (real_vector3d *)&p2);
|
||
```
|
||
|
||
Two things had to be right at once, and the measurements separate them cleanly:
|
||
|
||
* **The helper calls, not raw arithmetic.** Written as expressions, MSVC commons
|
||
and tightens the code and the function comes out 32 to 48 bytes short. Routed
|
||
through `scale_vector3d` / `add_vectors3d` / `subtract_vectors3d` the size lands
|
||
exactly.
|
||
* **`p2` reused as the scratch, not a new local.** A fresh `real_vector3d scaled`
|
||
gives the same size and constants but pushes the frame to `0x81c`. Reusing `p2`
|
||
-- which is overwritten by the subtraction on the last line anyway -- keeps
|
||
`sub esp` on `0x810`. This is the "January reuses locals as scratch" rule from
|
||
`vehicles.c` §10.
|
||
|
||
| | before | after |
|
||
|---|--:|--:|
|
||
| frame | 0x810 | **0x810** |
|
||
| size | 24,976 (+0) | **24,976 (+0)** |
|
||
| relocations | 1,909 (+4) | **1,906 (+1)** |
|
||
| surplus constants | 6 | **3** |
|
||
|
||
### Site C is the same shape but not the same size
|
||
|
||
Applying the identical rewrite at the `-0.3f` site removes the last three surplus
|
||
constants -- `extra` reaches **0** -- but the function comes out 16 bytes short
|
||
and relocations drop to 1,903, two *below* January. Three scratch choices were
|
||
tried (`p2`, `p0`, raw expressions) and all three land on the same 1,903 with
|
||
sizes 16, 32 and 48 short.
|
||
|
||
The relocation arithmetic is now consistent and worth stating, because it says
|
||
exactly what is left: 1,906 currently, minus the 3 surplus `-0.3f` at site C,
|
||
plus the 2 relocations January has that we are missing (`_global_up3d` and one
|
||
empty string literal, both elsewhere in the function) = **1,905**, January's
|
||
count. So site C's rewrite is correct on constants and the two missing
|
||
relocations are a separate, independent defect.
|
||
|
||
## State
|
||
|
||
`_ai_debug_render_actor`: frame `0x810` exact, size 24,976 exact, relocations
|
||
1,906 against 1,905, three surplus constants at site C and two missing
|
||
relocations elsewhere. Not credited; the object stays at 11/60 sections and
|
||
1,520/42,992 bytes.
|
||
|
||
## Both missing relocations located
|
||
|
||
### 1. The empty string: a transcription bug at line 2895
|
||
|
||
January emits two structurally identical `sprintf` blocks, each selecting between
|
||
`""` and `"not-"`:
|
||
|
||
```
|
||
04af1 mov eax, 0 ; "" 04b2d mov eax, 0 ; ""
|
||
04af8 mov eax, 0 ; "not-" 04b34 mov eax, 0 ; "not-"
|
||
04afe push 0 ; "melee-noanimation (%sairborne)"
|
||
04b3a push 0 ; "melee-success (%sairborne)"
|
||
```
|
||
|
||
Ours had only one. The second was written
|
||
|
||
```c
|
||
sprintf(temporary, "melee-success (%sairborne)", actor_debug_info->field_198 ? "not-" : "not-" );
|
||
```
|
||
|
||
-- both arms of the ternary identical, which is meaningless as written and is
|
||
plainly a transcription slip from the neighbouring line 2889. Corrected to
|
||
`? "" : "not-"`.
|
||
|
||
**This fix makes the size worse, and that is the useful part.** With the bug the
|
||
function was 24,976 -- exactly January's. Corrected it is 24,992. So the dead
|
||
ternary was compiling to 16 bytes less than January's real code and was masking a
|
||
**16-byte surplus somewhere else** in the function. The size match was a
|
||
coincidence of two defects cancelling. Keeping the correct source and carrying a
|
||
visible +16 is the better state to work from.
|
||
|
||
### 2. `_global_up3d`: the third marker branch
|
||
|
||
January references `_global_up3d` 49 times, we reference it 48. Aligning the two
|
||
lists, the missing site is January's `0x1285` -- the *third* marker branch, where
|
||
it loads the global before the 0.1f work:
|
||
|
||
```
|
||
01281 fld dword ptr [ebp - 0xc]
|
||
01284 mov eax, dword ptr [_global_up3d]
|
||
01289 fmul dword ptr [__real@3dcccccd]
|
||
```
|
||
|
||
Reverting the raw-expression rewrite at that branch (keeping it only at the
|
||
first) restores the reference -- `missing` drops to 1 -- but costs three surplus
|
||
constants and takes relocations from +1 to +5. Measured alternatives for that
|
||
branch:
|
||
|
||
| third branch as | size | relocations | surplus | missing |
|
||
|---|--:|--:|--:|--:|
|
||
| raw expressions (current) | +0 | +1 | 3 | 2 |
|
||
| `point_from_line3d` calls | +0 | +5 | 6 | 1 |
|
||
| scale/add/subtract helpers | -48 | +1 | 3 | 2 |
|
||
|
||
No form yet gets the reference back without paying for it elsewhere, so the
|
||
current state is retained as the closest overall.
|
||
|
||
## State
|
||
|
||
`_ai_debug_render_actor`: frame `0x810` exact, size 24,992 against 24,976,
|
||
relocations 1,907 against 1,905, three surplus constants at site C and one
|
||
missing `_global_up3d` at the third marker branch. Not credited; the object
|
||
stays at 11/60 sections and 1,520/42,992 bytes.
|
||
|
||
## The "16-byte surplus" is not one defect
|
||
|
||
Chasing it produced a more useful answer than a fix.
|
||
|
||
First, the accounting. The section is 24,992 against January's 24,976, but the
|
||
section is body + jump table + padding. Locating the table by its run of
|
||
4-byte-spaced relocations:
|
||
|
||
| | body | table | section |
|
||
|---|--:|--:|--:|
|
||
| January | 24,576 (`0x6000`) | 388 (97 entries) | 24,976 |
|
||
| ours | 24,604 (`0x601c`) | 388 (97 entries) | 24,992 |
|
||
|
||
The table is already exactly right -- 97 entries, same size -- so the switch is
|
||
correct. The **body is 28 bytes too long**, and padding absorbs 12 of that, which
|
||
is why the section only shows +16.
|
||
|
||
Second, where those 28 bytes are: **nowhere in particular**. Taking every
|
||
relocation target that occurs exactly once in both objects as a landmark and
|
||
plotting the drift between January's address and ours:
|
||
|
||
```
|
||
J 0x00eef drift -12 J 0x03220 drift +9
|
||
J 0x013fb drift -50 J 0x0379a drift +12
|
||
J 0x01c34 drift -41 J 0x03fd0 drift +16
|
||
J 0x02056 drift -27 J 0x044d3 drift +22
|
||
J 0x02d91 drift -6 J 0x04a77 drift +27
|
||
J 0x02eb4 drift -1 J 0x055d2 drift -31
|
||
J 0x0308e drift +5 J 0x05bf8 drift +31
|
||
J 0x05c7b drift +28
|
||
```
|
||
|
||
The drift swings from -50 to +31 and crosses zero repeatedly before settling at
|
||
+28. Around 240 blocks differ in size, most of them cancelling. There is no
|
||
single 28-byte mistake to find -- the body is a long tail of small deviations
|
||
that happen to net out near zero.
|
||
|
||
**This reframes the remaining work on this function.** Frame, jump table and
|
||
relocation count are now essentially right, and the earlier fixes were real
|
||
because each targeted a specific, identifiable construct. What is left is not
|
||
one more such fix; it is a few dozen small shape differences spread through
|
||
2,900 lines, each worth a handful of bytes. Closing it means walking the function
|
||
region by region against the disassembly, not hunting a single defect.
|
||
|
||
The landmark-drift table above is the tool for that: it localises which regions
|
||
carry surplus and which carry deficit, so the walk can be ordered by size of
|
||
error rather than done blindly.
|
||
|
||
## Region map for `_ai_debug_render_actor`
|
||
|
||
Built by taking every relocation target occurring exactly once in both objects
|
||
and plotting where our address drifts from January's. Each row is a place where
|
||
the drift *steps*, so it localises which region of the function carries the
|
||
error. This is the work plan for finishing the function.
|
||
|
||
| January addr | drift before -> after | delta | landmark |
|
||
|---|---|--:|---|
|
||
| 0x00ac8 | 0 -> -11 | **-11** | `"turn angvel..."` |
|
||
| 0x00eef | -8 -> -12 | -4 | `_unit_get_head_position` |
|
||
| 0x00ff0 -> 0x013fb | -12 -> -50 | **-38** | the three marker branches |
|
||
| 0x01c34 | -50 -> -41 | +9 | `_ai_communication_get_player_rating` |
|
||
| 0x01e1a | -41 -> -32 | +9 | `"prop->enemy"` |
|
||
| 0x02d91 | -21 -> -6 | **+15** | `_unit_estimate_position` |
|
||
| 0x0308e | -1 -> +5 | +6 | `_encounter_get_squad` |
|
||
| 0x044d3 | +16 -> +22 | +6 | `"flee"` |
|
||
| 0x0452d | +22 -> +16 | -6 | `"m-%s a-%s"` |
|
||
| 0x04a77 | +19 -> +27 | +8 | `"vehicle-success"` |
|
||
| 0x055d2 / 0x056c2 | +26 -> -31 -> +18 | -57/+49 | `_global_zero_vector3d` -- almost certainly a landmark mis-pairing, net -8 |
|
||
| 0x05a5d | +16 -> +29 | +13 | `_tag_get_name` |
|
||
|
||
Two things this settles.
|
||
|
||
**The marker branches are not the problem.** Every form tried there -- raw
|
||
expressions, original `point_from_line3d` calls, and the scale/add/subtract
|
||
helpers, in each combination across the first and third branch -- produces the
|
||
same total body of 24,604. They only move drift around locally (the `-50` at
|
||
`0x13fb` becomes `-23` with the original calls) without changing the total. The
|
||
region is internally wrong but self-cancelling.
|
||
|
||
**The +28 is distributed.** Positive steps at `0x2d91` (+15), `0x5a5d` (+13),
|
||
`0x1c34` and `0x1e1a` (+9 each), `0x308e`, `0x44d3`, `0x4a77` (+6 to +8) are
|
||
where our code is longer than January's, and negative steps elsewhere partly
|
||
offset them. No single region accounts for the residual.
|
||
|
||
Ordering the walk by |delta| gives the sequence to work through: the marker
|
||
region (-38, though self-cancelling), `_unit_estimate_position` (+15),
|
||
`_tag_get_name` (+13), the two +9s, then the +6s and +8s.
|
||
|
||
## Working the region map: chunk 0x1c00 -- a wrong prototype
|
||
|
||
Bucketing the body into `0x400` chunks and taking the net drift change per chunk
|
||
gives a clean ranking of where our code runs long:
|
||
|
||
| delta | January range |
|
||
|--:|---|
|
||
| **+23** | 0x01c00-0x01fff |
|
||
| +18 | 0x04800-0x04bff |
|
||
| +15 | 0x05800-0x05bff |
|
||
| +14 | 0x02c00-0x02fff |
|
||
| +12 | 0x02800-0x02bff |
|
||
| -38 | 0x01000-0x013ff (the marker branches, self-cancelling) |
|
||
|
||
Resolving the largest, the biggest single step inside it is +9 at `0x1c34`, and
|
||
the two sides differ like this:
|
||
|
||
```
|
||
January cmp eax,-1 / je / push 0,0,1 / push eax / call
|
||
ours cmp eax,-1 / mov [ebp-0x48],eax / je / fild [ebp-0x48]
|
||
/ push 0,0,1 / push ecx / fstp [esp]
|
||
```
|
||
|
||
January passes the unit index as an **integer**; we spill it, convert with
|
||
`fild`, and push a **float**. The cause is in `source/ai/ai_communication.h`:
|
||
|
||
```c
|
||
real ai_communication_get_player_rating(real unit_index, ...);
|
||
```
|
||
|
||
`unit_index` is declared `real`. It is an index. Corrected to `long`.
|
||
|
||
| | before | after |
|
||
|---|--:|--:|
|
||
| body | 24,604 (+28) | **24,588 (+12)** |
|
||
| section size | 24,992 (+16) | **24,976 (+0)** |
|
||
| relocations | 1,907 | 1,907 (+2) |
|
||
|
||
The section size is exact again -- and this time it is exact with *both* the
|
||
ternary and the prototype correct, rather than two defects cancelling as before.
|
||
Checked for fallout: `ai_communication` itself is unchanged at 2/48, and
|
||
`objects`, `matrix_math` and `vehicles` are all unmoved.
|
||
|
||
Remaining body surplus is +12, spread over the chunks listed above.
|
||
|
||
## Working the region map: a second type defect
|
||
|
||
Rather than walk every chunk, searching for the *signature* of the prototype bug
|
||
-- conversion instructions we emit that January does not -- found the next one
|
||
directly:
|
||
|
||
| | January | ours |
|
||
|---|--:|--:|
|
||
| `fild` | 2 | 2 |
|
||
| `fisub` | 1 | 1 |
|
||
| **`fistp`** | **0** | **1** |
|
||
|
||
The single surplus `fistp` is at our `0x39ad`, in the `"newtarget %d"` call:
|
||
|
||
```
|
||
January fld [ecx+0x88] / fmul 30.0f / fisub [esi+0x61c]
|
||
sub esp,8 / fstp qword ptr [esp] <- pushes a double
|
||
ours fld [ecx+0x88] / fmul 30.0f / fisub [esi+0x61c]
|
||
fistp qword ptr [ebp-0x14] / mov edx,[ebp-0x14] / push edx <- pushes an int
|
||
```
|
||
|
||
Our source carried two casts January does not have:
|
||
|
||
```c
|
||
"newtarget %d",
|
||
(unsigned long)(
|
||
...new_target_pattern_time*TICKS_PER_SECOND -
|
||
(real)actor->control.current_fire_target_timer
|
||
)
|
||
```
|
||
|
||
The `(unsigned long)` produces the `fistp`. The `(real)` is also wrong: January
|
||
emits `fisub`, an *integer* subtract from a float, which is what C does for
|
||
`float - long` with no cast. Both removed.
|
||
|
||
Note what this means. January passes a floating-point value to `%d` -- a genuine
|
||
type bug in the original that the reconstruction had silently "fixed" with a
|
||
cast. Reproducing the original faithfully means reproducing the bug. Only 1 byte
|
||
of size, but every conversion instruction now matches exactly.
|
||
|
||
## State
|
||
|
||
`_ai_debug_render_actor`: frame `0x810` exact, section size 24,976 **exact**,
|
||
body +12, relocations 1,907 against 1,905, three surplus constants, one missing
|
||
`_global_up3d`. Not credited; object 11/60 sections, 1,520/42,992 bytes.
|
||
|
||
Two real defects found by this walk so far -- a `real` parameter that should be
|
||
`long`, and two spurious casts -- both of them type errors rather than shape
|
||
errors. Remaining chunk ranking: 0x04800 (+21), 0x05800 (+15), 0x02c00 (+14),
|
||
0x02800 (+12); spot checks in the first of those show scheduling and
|
||
register-allocation differences rather than further bugs.
|
||
|
||
### The unsigned-compare tell, and where the walk stops
|
||
|
||
`jbe` appears once in ours and never in January. At our `0x310b`:
|
||
|
||
```
|
||
mov al, byte ptr [esi + 0xbc]
|
||
test al, al
|
||
jbe 0x38eb <- unsigned; January uses je for a truthiness test
|
||
```
|
||
|
||
The comparable January code elsewhere reads `test cx,cx / jle`, a *signed* test.
|
||
So somewhere a `> 0` is written against a field where January tests truth
|
||
directly, or against an unsigned type where January's is signed. Two candidates
|
||
were tried -- `flee.has_approach_point > 0` at line 1899 and
|
||
`alert.move_position_order > 0` at 1905 -- and neither changes a byte, so the
|
||
site is elsewhere in the surrounding switch. Left open.
|
||
|
||
## Where this walk ends
|
||
|
||
Two genuine defects came out of it, both **type** errors:
|
||
|
||
1. `ai_communication_get_player_rating` declared `real unit_index` for an index.
|
||
Worth 16 bytes and restored the exact section size honestly.
|
||
2. Two spurious casts at the `"newtarget %d"` call -- an `(unsigned long)` around
|
||
the expression and a `(real)` on the timer. Worth 1 byte, but it made every
|
||
conversion instruction match: `fild` 2/2, `fisub` 1/1, `fistp` 0/0.
|
||
|
||
The productive technique was **not** walking the drift map region by region. It
|
||
was diffing the *instruction mix* and chasing mnemonics that betray a type
|
||
mismatch -- `fistp`, `fild`, `jbe`, `movsx`. Those find real bugs cheaply. The
|
||
drift map finds shape differences, and every shape difference examined in the top
|
||
chunks (`0x4800`, `0x2c00`) turned out to be scheduling or register allocation:
|
||
the same class as the two resistant negation sites, not source-controllable.
|
||
|
||
Current: frame `0x810` exact, section size **24,976 exact**, body +12,
|
||
relocations 1,907 against 1,905, three surplus constants, one missing
|
||
`_global_up3d`, one surplus `jbe`. Object unchanged at 11/60, 3.5%.
|
||
|
||
For anyone continuing: run the instruction-mix diff first and exhaust the type
|
||
tells, then accept that the remaining size delta is distributed scheduling noise
|
||
rather than hunting it.
|
||
|
||
## Correction: the residual was not scheduling noise
|
||
|
||
The previous section concluded the remaining delta was distributed scheduling
|
||
noise and recommended against hunting it. That was wrong, and the way it was
|
||
wrong is worth recording: the drift map was being read as the primary signal,
|
||
and it is too noisy to separate a real defect from allocation churn. Ranking the
|
||
*relocation multiset* instead put two genuine defects on the table immediately.
|
||
|
||
### The fifth point is real (January 0x1140)
|
||
|
||
January's PVS branch opens by copying `base_point` into a distinct local:
|
||
|
||
```
|
||
01140 mov eax,[ebp-0x14] mov edx,[ebp-0x18] mov ecx,[ebp-0x10]
|
||
01149 mov [ebp-0x110],eax mov [ebp-0x114],edx mov [ebp-0x10c],ecx
|
||
01160 fld dword ptr [eax] <- only then the 0.2f scale
|
||
```
|
||
|
||
`[ebp-0x114]` is later passed to `_render_debug_line` twice. The four calls form
|
||
a closed quad P->Q, Q->R, R->S, S->P, and P is that copy, not `base_point`
|
||
itself. So the branch declares five points and opens with `p4 = base_point;`,
|
||
passing `&p4` in the first and last calls. This is the local deleted earlier on
|
||
the grounds that removing it made the frame come out at 0x810; that agreement was
|
||
a coincidence between two errors, and restoring the copy is what closed the one
|
||
missing `_global_up3d` relocation.
|
||
|
||
### point_from_line3d is inlined, so a negative scalar costs three constants
|
||
|
||
Line 2937 read `point_from_line3d(&p1, &v, -0.3f, &p2);`. Because the callee
|
||
inlines component-wise, that spends three references to `__real@be99999a`, and
|
||
January references -0.3 nowhere in the function. January instead scales once and
|
||
then adds and subtracts, spilling only the z component:
|
||
|
||
```
|
||
04d48 fld [ebp-0x4c] fmul 0.3 fst [ebp-0x20] fadd [ebp-4] fstp [ebp-0x10]
|
||
04d5a fld [ebp-0xc] fsub st(2) fstp [ebp-0x84]
|
||
```
|
||
|
||
That is the same scale/add/subtract idiom already recovered at line 1011, with
|
||
`p2` reused as the scratch vector. Applying it removed all three surplus
|
||
constants without disturbing the `+0.3f` count.
|
||
|
||
**Relocations now match exactly: 1905 against 1905, with the surplus and missing
|
||
sets both empty.** Remaining: frame 0x81c against 0x810, so exactly one surplus
|
||
12-byte point elsewhere in the function, and size 25008 against 24976.
|
||
|
||
Method note for whoever continues: rank by relocation multiset first, not by the
|
||
drift map. A surplus or missing relocation is always a real source-level
|
||
difference; a drift step very often is not.
|
||
|
||
## Third defect: horizontal_angle is a minimum
|
||
|
||
The vision-cone setup at line 3188 read the peripheral vision angle against the
|
||
iterated angle as a maximum. January loads the local first and branches to the
|
||
arm that yields it:
|
||
|
||
```
|
||
05580 fld [ebp-0x1c] fcomp [ecx+0x28] test ah,0x41 jne 0x5595
|
||
0558d mov ecx,[ecx+0x28] mov [ebp-0x2c],ecx <- A > P, take P
|
||
05595 mov edx,[ebp-0x1c] mov [ebp-0x2c],edx <- A <= P, take A
|
||
```
|
||
|
||
`test ah,0x41` is zero only when ST(0) > operand, so `jne` is taken on `A <= P`.
|
||
That is `A > P ? P : A`, a minimum. Ours loaded the definition field first, which
|
||
is `P > A ? P : A`. After the swap the compare, the branch and both arms match
|
||
instruction for instruction. The two forms are the same length, so this never
|
||
showed up as a size change -- it was found by reading branch polarity.
|
||
|
||
## Where it stands
|
||
|
||
Relocations match exactly, 1905 against 1905, surplus and missing both empty.
|
||
Outstanding: frame 0x81c against 0x810, and size 25008 against 24976.
|
||
|
||
The frame delta is exactly one 12-byte point. Two localisation methods were
|
||
tried and both failed, which is worth recording so they are not repeated:
|
||
|
||
* **Offset-boundary.** If one local were surplus, slots shallower than it would
|
||
keep their offsets and deeper ones would shift by 12. Pairing offsets through
|
||
matched landmarks shows the big buffers (0x2b0, 0x410, 0x510, 0x810) do shift
|
||
by exactly 12, but the small locals are *permuted*, not shifted -- 0x1b0 maps
|
||
to 0x1a4, 0x184 to 0x1b4. Adding a local made the allocator reorder, so there
|
||
is no boundary to read.
|
||
* **Slot counting per aligned region.** Counting distinct slots between matched
|
||
landmarks flagged January 0x5069-0x526d, 5 slots against 11. Inspection shows
|
||
the block itself is faithful: it is the `field_F4` branch at line 3007, and
|
||
both builds copy the source struct twice rather than copying `p4` into `p3`.
|
||
The extra live slots are the enclosing block's `p0`/`p1`, which January retires
|
||
before the inner block so that `p3`/`p4` reuse their slots, while ours keeps
|
||
them live and allocates fresh ones. That is a *consequence* of the surplus
|
||
local, not its location.
|
||
|
||
Caution for the next attempt: capstone prints small displacements without the
|
||
`0x` prefix, so a regex of `ebp - (0x[0-9a-f]+)` silently drops `[ebp-4]` and
|
||
`[ebp-8]`. Both failed methods were skewed by this before it was caught.
|
||
|
||
## Lifetime analysis: what it settled, and what it did not
|
||
|
||
### VC7 packs sibling scopes for points, but never for arrays
|
||
|
||
January's slot `ebp-0xc` holds `offset_vector.i` near 0x1281 and an unrelated
|
||
crosshair point near 0x5087; `ebp-0x18` likewise holds `base_point.x` and then a
|
||
different point. Those are different variables in disjoint sibling scopes sharing
|
||
one slot, so the allocator does pack.
|
||
|
||
The five big buffers behave the opposite way. They are referenced from disjoint
|
||
regions -- 0x2b0 near 0xd9e, 0x510 near 0x1cf0, 0x410 near 0x2317, 0x710 near
|
||
0x33c8, 0x310 near 0x5781 -- and yet their sizes *sum*: 0x60 + 0x100 + 0x100 +
|
||
0x200 + 0x100 = 0x560, and 0x560 plus the 0x2b0 of small locals is exactly the
|
||
0x810 frame. Arrays whose address escapes to `sprintf`/`csstrlen` are never
|
||
packed.
|
||
|
||
So the frame is `sum(arrays) + max over scope chains(points)`, and the 12-byte
|
||
delta lives entirely in the small-locals term: 0x2b0 in January against 0x2bc in
|
||
ours. The surplus point is on our deepest scope chain.
|
||
|
||
### The inner crosshair reuses the outer points
|
||
|
||
January's outer crosshair (lines 2991-3005) uses `ebp-0x18/-0x14/-0x10` and
|
||
`ebp-0xc/-8/-4`. Its inner crosshair at 0x5087, inside `if (field_F4)`, uses
|
||
*those same six slots*. The inner block is lexically nested inside the outer one,
|
||
so packing cannot apply -- same slots therefore means the same variables.
|
||
January's inner block has no `p3`/`p4`; it reuses `p0` and `p1`.
|
||
|
||
Rewriting it that way is size-neutral, because the allocator had already
|
||
coalesced them, but it does change our slot assignment to match January's and it
|
||
removes a +6 dword excess that the pressure profile had flagged at 0x50cb. Kept
|
||
on fidelity grounds, not on size.
|
||
|
||
### Methods that produced artifacts, with the reason
|
||
|
||
Five statistical localisations were tried; all four of the failures produced
|
||
plausible-looking but false candidates, so they are recorded here rather than
|
||
left for someone to rediscover.
|
||
|
||
* **Offset boundary.** Fails because adding a local permutes the small locals
|
||
rather than shifting them.
|
||
* **Distinct slots per aligned region.** Flagged 0x5069-0x526d at 5 slots against
|
||
11. Real, but it is the *consequence* of the inner-crosshair aliasing above,
|
||
not an independent defect.
|
||
* **Slots with no preimage under vote-mapping.** Named `0xb8`, `0xc0`, `0x31c`,
|
||
`0x71c`. All four are false: `0x31c`/`0x71c` are January's `0x310`/`0x710`
|
||
losing a vote, and `0xc0` is one of branch 1's four legitimate points --
|
||
branch 1 addresses exactly four locals in both builds.
|
||
* **Address-taken locals aligned by first use.** Reports several extras, but the
|
||
alignment is exact-address matching on drift-normalised positions, which
|
||
desynchronises after 0x4278. Its one robust output is a count: January takes
|
||
the address of 30 locals, ours 32.
|
||
|
||
### The experiment that would settle it
|
||
|
||
Because arrays sum and points are max-over-chains, the surplus is on the deepest
|
||
point chain. Add an address-taken 12-byte dummy to one scope at a time and
|
||
rebuild: the frame grows to 0x828 only when that scope lies on the critical
|
||
chain. Probing the outermost scope is the control -- it must always grow. That
|
||
identifies the chain in a handful of builds, after which the declarations in
|
||
those scopes can be compared against January's addressed locals directly, which
|
||
is tractable once the candidate set is a few scopes rather than 96 declarations.
|
||
|
||
## The probe experiment: the surplus is in the cluster_index region
|
||
|
||
Inserting a self-contained address-taken dummy block as a statement,
|
||
|
||
```c
|
||
{ real_point3d zzprobe; zzprobe.x = 0.f; zzprobe.y = 0.f; zzprobe.z = 0.f;
|
||
render_debug_point(TRUE, &zzprobe, 0.f, global_real_argb_red); }
|
||
```
|
||
|
||
grows the frame only when its ancestor path is the critical chain. Results:
|
||
|
||
| insertion point | frame |
|
||
|------------------------------------------|--------|
|
||
| line 641, prop rendering | 0x81c unchanged |
|
||
| line 2927, vehicle avoidance | 0x81c unchanged |
|
||
| line 3216, vision cone | 0x81c unchanged |
|
||
| before `if (cluster_index==NONE)` | **0x828** |
|
||
| branch 1, `cluster_index==NONE` | **0x828** |
|
||
| branch 2, PVS set | **0x828** |
|
||
| branch 3, else | **0x828** |
|
||
|
||
So the chain that sizes the frame is the `cluster_index` if-chain, roughly lines
|
||
952-1040, and nothing outside it participates. That narrows the surplus from 96
|
||
declarations across the function to about fifteen in one region.
|
||
|
||
Within the region the per-branch counts already agree with January: branch 1
|
||
addresses four locals in both (`0x40 0x70 0x18c 0x1b0` in January), branch 2
|
||
four, branch 3 three. The enclosing scope declares only `offset_vector`,
|
||
`base_point` and a `short`, which is minimal.
|
||
|
||
Note the probe also settles that `p4` in branch 2 is a real source variable
|
||
rather than a compiler temp. `base_point` is address-taken at line 960 by
|
||
`unit_get_head_position`, so it already has a stack home and MSVC would pass its
|
||
address directly; the copy into `ebp-0x114` can only be a second, distinct
|
||
variable.
|
||
|
||
The remaining question is which member of that region January does not have, or
|
||
which pair of them it aliases. Every count checked so far agrees, so the next
|
||
step is to decode January's addressed locals in 0x100e-0x1382 to specific source
|
||
variables -- branch 1 is already done, `p0=0x40 p1=0x70 p2=0x18c p3=0x1b0` -- and
|
||
do the same for branch 3, where January addresses three locals for four declared
|
||
points.
|
||
|
||
## The -53/+39 pair at global_zero_vector3d is a landmark artifact
|
||
|
||
It is not a missing block. January's inner loop back-edge is `jne 0x55d0` at
|
||
0x568c, and 0x55d0 is `mov edx,[_global_zero_vector3d]` -- the load is the first
|
||
instruction *of the loop body*, re-executed every iteration. Our back-edge is
|
||
`jne 0x55d1`, and 0x55d1 is `mov ebx,[edx]`: our pointer is already in `edx` and
|
||
stays live across iterations, so our load sits above the loop.
|
||
|
||
The same relocation therefore lands ~50 bytes further along in ours than in
|
||
January purely because of which side of the loop head it falls on. The apparent
|
||
-53 and the +39 that follows are two halves of that one displacement, not two
|
||
defects.
|
||
|
||
### Why January reloads
|
||
|
||
January destroys the pointer reading the third component:
|
||
|
||
```
|
||
055dc mov ebx,[edx] 055ff mov edx,[edx+8] <- edx gone
|
||
05602 mov [edi+4],ebx 05605 mov [edi+8],edx
|
||
```
|
||
|
||
Ours uses `ebx` for all three and keeps `edx` intact. January needs the extra
|
||
register because it is under more pressure: it spills *both* induction variables
|
||
to memory (`[ebp-0x58]` the byte index, `[ebp-0x30]` the countdown) and
|
||
materialises a separate element pointer (`lea ecx,[eax-8]` / `mov edi,ecx`),
|
||
where we hold the index and counter in `ecx` and `edi`.
|
||
|
||
That accounts for the residual size too. January's inner loop body is 188 bytes
|
||
against our 177: the memory-resident induction variables cost it about 13 bytes
|
||
and the element pointer about 5, offset by about 4 where `[ecx]` addressing beats
|
||
our `[eax-0x14]`.
|
||
|
||
### Rejected: the subobject pointer
|
||
|
||
The dedicated element pointer looks like the subobject-pointer idiom, so it was
|
||
tried -- hoisting `real_point3d *direction = &direction_vector[side][ring]` and
|
||
using it for the three stores and the three `point_from_line3d` calls. It moves
|
||
size the wrong way, from -16 to **-80**: MSVC then optimises the whole loop
|
||
harder, and January is *longer* than us here, not shorter. Reverted.
|
||
|
||
So the pair is explained but not closed. What remains is a register-allocation
|
||
difference in one loop, and the direction of the residual (-16, we are shorter)
|
||
says the missing bytes are spills January makes and we do not.
|
||
|
||
## The frame surplus is not a declared local
|
||
|
||
Attempting to close the frame and the -16 together. The probe identified the
|
||
critical chain, but a probe cannot say *where* along a chain the surplus sits,
|
||
only which chain it is on. So the chain was enumerated directly.
|
||
|
||
Addressed locals, January against ours, over the whole chain:
|
||
|
||
| January range | JAN | OUR |
|
||
|---------------|-----|-----|
|
||
| 0x0600-0x0a00 | 5 | 5 |
|
||
| 0x0a00-0x0d00 | 2 | 2 |
|
||
| 0x0d00-0x0f40 | 2 | 2 |
|
||
| 0x0f40-0x1382 (the if-chain) | 12 | 12 |
|
||
| **0x0600-0x1382 total** | **15** | **15** |
|
||
|
||
Individually too: branch 1 addresses four in both, branch 3 four in both, branch 2
|
||
four plus `p1`, and the enclosing scope holds `offset_vector` at `ebp-0xc` and
|
||
`base_point` at `ebp-0x18` in both. Every source-declared point on the chain is
|
||
accounted for on both sides.
|
||
|
||
So the 12 bytes are **not a declared local**. They are spill or temporary slots,
|
||
which means the frame delta is a register-allocation outcome rather than
|
||
something a declaration can be added or removed to fix.
|
||
|
||
### Three rewrites tried and rejected
|
||
|
||
All three were aimed at removing a 12-byte point from branch 2; each made things
|
||
worse and was reverted.
|
||
|
||
* `p1` as three `real` scalars -- frame **0x820**, size **+128**. The scalars
|
||
take a slot each plus alignment, costing 16 where the point cost 12.
|
||
* `p1` eliminated, relying on CSE of `base_point + up*0.1` -- frame **0x820**,
|
||
size **+128**, and three *surplus* `0.1` constants: MSVC recomputes the
|
||
subexpression rather than CSE-ing it.
|
||
* subobject pointer for `direction_vector[side][ring]` -- size **-80**, the wrong
|
||
direction, since January is longer than us there.
|
||
|
||
### What this means for the -16
|
||
|
||
The -16 has the same character. January's vision-cone inner loop spills both
|
||
induction variables to memory where we keep them in registers, which is most of
|
||
the eleven bytes by which its loop body exceeds ours. So both residuals are the
|
||
same phenomenon -- our allocator is under less pressure than January's on this
|
||
chain -- and they will close together when the remaining expression shapes are
|
||
exact, not by a targeted edit to either one.
|
||
|
||
That is a real constraint, not a hedge: the frame is set by slots the allocator
|
||
chose, so there is no declaration to delete. The productive direction is to keep
|
||
correcting expression shapes, as the `headspace_vector.j` hoist and the branch 2
|
||
`p1` change both did, and let the allocation follow.
|
||
|
||
## Writing the absent functions
|
||
|
||
The correct inventory is **46 absent sections, 15,584 bytes** (an earlier note
|
||
said 49; that was wrong). Progress this pass: 11/60 sections and 1520 bytes to
|
||
**18/60 and 1968 bytes**.
|
||
|
||
Written and byte-exact: `ai_debug_sound_point_set`, `ai_debug_lineoffire_new`,
|
||
`ai_debug_lineoffire_success`, `ai_debug_lineofsight_reset`,
|
||
`ai_debug_idle_look_clear`, `ai_debug_lineoffire_addpill`,
|
||
`ai_debug_idle_look_addprop`.
|
||
|
||
### Field names come from the object, not from guesswork
|
||
|
||
`ai_debug_lineoffire_addpill` asserts on its validity flag, and the assert string
|
||
survives in the object as `ai_debug.lineoffire_valid`, which fixes 0xF8 exactly.
|
||
`ai_debug_idle_look_addprop` gives `ai_debug.idle_look_valid` at 0x85A14 the same
|
||
way. Everything else in those two blocks follows from the code: each count is
|
||
compared against its bound before use (16 and 32), and each indexes parallel
|
||
arrays whose base offsets differ by exactly bound x element size. Where no such
|
||
evidence exists the file's existing `field_XXX` convention is used --
|
||
`field_2EC` and `field_402F0` are simply the two longs `lineofsight_reset`
|
||
clears.
|
||
|
||
`hs_globals_external.c` names `ai_debug` fields up to 0xF3 via its script-global
|
||
table, and `hs.c` carries real prototypes -- `ai_debug_sound_point_set` is
|
||
declared there as taking `void`, which is why an empty body is right.
|
||
|
||
### Asserts pin January's line numbers
|
||
|
||
An assert compiles its line number in as an immediate: 0xfc4 is line 4036 and
|
||
0x13c6 is line 5062. Our file is ~3600 lines, so these can never agree by
|
||
placement. The project already solves this -- `match_assert(file, line, expr)`
|
||
in cseries.h takes the file and line explicitly, and `MATCH_LINE` uses them
|
||
unless NON_MATCHING is defined. So any absent function containing an assert is
|
||
still writable today; it just has to carry January's literal line number.
|
||
|
||
### Environment note
|
||
|
||
The Bash tool here collapses `\` to `\` inside heredocs, including quoted ones.
|
||
That silently turned `"c:\halo\SOURCE\ai\ai_debug.c"` into a path containing
|
||
a bell character, and the only symptom was one mismatched relocation on the
|
||
file-name string while every byte of code was already identical. Write C string
|
||
literals containing backslashes with the Write/Edit tools, or build them in
|
||
Python from `chr(92)`.
|
||
|
||
## The last two lineofsight functions: what the residual actually is
|
||
|
||
`code_00037dd0` is byte-exact. `code_00037ee0` and `ai_debug_lineofsight` are
|
||
both at exactly January's size with one difference each, and both differences
|
||
are the *same* difference seen from two ends.
|
||
|
||
In `ai_debug_lineofsight` the only differing bytes are:
|
||
|
||
```
|
||
JANUARY 33f6 xor esi, esi OURS 8bf0 mov esi, eax
|
||
668bf0 mov si, ax
|
||
```
|
||
|
||
January zero-extends the 16-bit result into `esi`; we move the full 32 bits.
|
||
Everything after is identical, shifted by three. The same three bytes account for
|
||
`code_00037ee0`, whose entry is `push esi / mov si, ax` against our reading `si`
|
||
directly.
|
||
|
||
### Two rejected attempts, and what they establish
|
||
|
||
* **Caller local as `unsigned short`.** No effect at all. The conversion does not
|
||
drive the extension, because the value is only ever consumed as a 16-bit
|
||
argument.
|
||
* **Callee parameters as `unsigned short`.** Actively worse: MSVC *abandons the
|
||
register convention entirely* and falls back to a stack frame --
|
||
`push ebp / mov ebp,esp / movzx esi,[ebp+8]` -- taking the function from 192 to
|
||
208 bytes.
|
||
|
||
That second result is the useful one. The custom register convention VC7 invents
|
||
for a static is **sensitive to the exact parameter type**: `short` earns it,
|
||
`unsigned short` loses it. So the convention, and with it whether the caller
|
||
zero-extends, is chosen by the compiler from the callee signature, and the
|
||
signature that produces the convention at all is the one we already have.
|
||
|
||
This is therefore not a spelling that can be reached by changing types. It is the
|
||
same class as the earlier allocation ties: correct code, one encoding choice
|
||
apart. Both functions stay NonMatching.
|
||
|
||
### Correction: they were not ties
|
||
|
||
The section above concluded both functions were an unreachable encoding choice.
|
||
That was wrong, and wrong in an instructive way: the reasoning stopped at "the
|
||
compiler picks this, not the source" without asking what *source* would make the
|
||
compiler pick it. Two ordinary spellings did.
|
||
|
||
**`ai_debug_lineofsight` has no named locals.** The call is a single nested
|
||
expression:
|
||
|
||
```c
|
||
code_00037ee0(code_00037dd0(start, start_key), code_00037dd0(end, end_key));
|
||
```
|
||
|
||
With named `short` locals MSVC keeps the full 32-bit return in `esi`, because it
|
||
can see only 16 bits are consumed. With a bare rvalue it materialises the short
|
||
properly, `xor esi,esi` then `mov si,ax` -- exactly January. That was the whole
|
||
three-byte difference, and the function went byte-exact immediately.
|
||
|
||
**`code_00037ee0` returns `long`, not `short`.** Its result is discarded at the
|
||
only call site, so nothing constrains it, and January gives it away in one byte:
|
||
the shared exit is `mov eax,esi`, a 32-bit move, where a `short` return compiles
|
||
to the 3-byte `mov ax,si`. Its sibling `code_00037dd0` really does return
|
||
`short`, and really does emit `mov ax,si` -- the two differ in January itself,
|
||
which is the tell that should have been read the first time.
|
||
|
||
All three lineofsight functions are byte-exact. Object at 25/60 sections,
|
||
3120/42992 bytes.
|
||
|
||
The general lesson, since this is the second time in this object: "the compiler
|
||
chose it" is a hypothesis about the source, not a verdict. A difference is only a
|
||
tie once a specific source question has been asked and answered.
|
||
|
||
## ai_debug_update: analysis of the first 0xd0 bytes
|
||
|
||
Not written. Recording what the head establishes so the next attempt starts here
|
||
rather than re-deriving it.
|
||
|
||
`ai_debug_update` is 1168 bytes, frame 0x6c, and saves ebx/esi/edi. It opens with
|
||
three independent guarded blocks:
|
||
|
||
1. **`ai_debug.render_lineofsight` (0xAA, already named)** gates two stores that
|
||
zero `lineofsight_point_count` and `lineofsight_pair_count` -- byte for byte
|
||
what `ai_debug_lineofsight_reset` does. Whether the source calls that function
|
||
and MSVC inlines it, or writes the two stores directly, is an open question
|
||
and worth settling by trying the call first.
|
||
|
||
2. **Three new booleans at 0x3C, 0x3D and 0x3E**, currently inside
|
||
`__unknown3C[24]`. 0x3C gates the entire rest of the function -- `je` jumps
|
||
past everything to 0x2d7. 0x3D and 0x3E each gate one of the blocks below.
|
||
|
||
3. **A player-surface record at 0x4C7E8**, inside `__unknown4C2F4`. When 0x3D is
|
||
clear, the function takes the local player's unit
|
||
(`player_control_get_unit_index(0)`), verifies it with
|
||
`object_try_and_get_and_verify_type`, calls
|
||
`biped_find_pathfinding_surface_index`, and on success fills:
|
||
|
||
| offset | contents |
|
||
|--------|----------|
|
||
| 0x4C7E8 | `boolean` valid, set last |
|
||
| 0x4C7EC | `real_point3d`, the out-parameter at `ebp-0x1c` |
|
||
| 0x4C7F8 | `long` surface index, the return value |
|
||
| 0x4C7FC | `long` unit index |
|
||
|
||
The point is written from three dwords read out of `ebp-0x1c`, so
|
||
`biped_find_pathfinding_surface_index(unit_index, &point)` returns the surface
|
||
index and fills a point.
|
||
|
||
4. When 0x3E is clear, it calls `observer_get_camera(0)`, and on a non-null
|
||
result increments a counter in `ai_profile` at 0xb34 and begins scaling
|
||
`global_down3d` by `__real@447a0000`, which is 1000.0f -- a downward ray a
|
||
kilometre long, so this is a screen-pick or ground-probe.
|
||
|
||
The function is written against `ai_profile` as well as `ai_debug`, so it will
|
||
need that structure available too.
|
||
|
||
### ai_debug_update, 0xd0 to 0x1f0
|
||
|
||
The camera block ends by casting a ray: `collision_test_vector(0x21, origin,
|
||
&scaled_down_vector, -1, &result)` with the result buffer at `ebp-0x6c`. On a hit
|
||
it fills a second record next to the first:
|
||
|
||
| offset | contents |
|
||
|--------|----------|
|
||
| 0x4C800 | `boolean` valid |
|
||
| 0x4C804 | `real_point3d`, read from the result at `ebp-0x54` |
|
||
| 0x4C810 | `long`, read from `ebp-0x28` of the result |
|
||
| 0x4C814 | `long`, stored as 0 |
|
||
|
||
Then, gated on the *first* record's validity at 0x4C7E8, it builds a path query:
|
||
|
||
* `path_input_new(&input, 0, unit_index, 0x3e4ccccd)` -- the constant is 0.2f
|
||
* `path_input_set_start(&input, &ai_debug.<point at 0x4C7EC>, surface_index)`
|
||
* if `ai_debug.<real at 0x40>` is non-zero, `path_input_set_search_bounds(&input, that)`
|
||
|
||
Four more scalars appear, all in `__unknown3C`: a `real` at 0x40, a `boolean` at
|
||
0x44, and `real`s at 0x48 and 0x4C. The 0x48 and 0x4C ones are each compared
|
||
against zero and defaulted when unset -- 0x4C defaults to `__real@41a00000`,
|
||
which is 20.0f. `test ah,0x44` with `jp` is the unordered/equal test, so these
|
||
read as `x!=0.f ? x : default`.
|
||
|
||
So `__unknown3C` is not padding, it is the debug pathfinding query parameters:
|
||
0x3C..0x3E gating booleans, 0x40 search bounds, 0x44 a "use player as
|
||
destination" flag, 0x48 and 0x4C two defaulted reals.
|
||
|
||
Still unwritten. The remaining ~0xe7 to 0x490 has not been read.
|
||
|
||
## The 64-byte record array: base and extent proved
|
||
|
||
Scanning every January section for `_ai_debug` relocations and reading the
|
||
displacement out of the instruction gives the owners of that region:
|
||
|
||
| function | distinct offsets |
|
||
|----------|------------------|
|
||
| `code_00041220` (2336 b) | 14, incl. 7d380 7d3c7 7d980 7d988 7d98c 7d990 7d9f4 7d9f5 7da18 7da1c 7da20 7da28 |
|
||
| `code_000383d0` (512 b) | 859f4 859f8 859f9 859fc 85a00 85a04 85a08 85a0c 85a10 |
|
||
| `ai_debug_select_encounter` (80 b) | 7d380 7d384 7d9f4 |
|
||
|
||
`code_00041220` is the writer. Its offsets settle the layout without needing the
|
||
function read: taking the record base as **0x7D9F4** with the known 0x40 stride,
|
||
its references land at +0, +1, +0x24, +0x28, +0x2c and +0x34 -- all inside one
|
||
record, and nothing outside it.
|
||
|
||
The extent then proves itself. 512 records of 0x40 from 0x7D9F4 ends at
|
||
**0x859F4**, and `code_000383d0`'s lowest reference is 0x859F4 exactly. Two
|
||
functions that never touch each other agree on the boundary, so the base and the
|
||
count are both right, and the byte the clearing loop writes is field +1 of each
|
||
record.
|
||
|
||
Everything above 0x859F4 up to the idle-look block at 0x85A14 belongs to
|
||
`code_000383d0`, at 4-byte spacing, so that is a small scalar block rather than
|
||
more array.
|
||
|
||
### Order to attack
|
||
|
||
`ai_debug_select_encounter` is 80 bytes and touches only 7d380, 7d384 and the
|
||
array base 7d9f4. It is the cheapest way to fix the first two fields and confirm
|
||
the base, and it unblocks `ai_debug_select_actor` and
|
||
`ai_debug_initialize_for_new_map`, which are otherwise complete -- both already
|
||
end by inlining the recovered `ai_debug_idle_look_clear`.
|
||
|
||
### select_actor: the inline hypothesis was wrong
|
||
|
||
The previous entry guessed the sixteen missing bytes were `ai_debug_idle_look_clear`
|
||
emitted as a call rather than inlined. It is inlined -- the five stores are
|
||
present at 0x42 through 0x53, and the guess was arithmetic-driven rather than
|
||
read off the build.
|
||
|
||
Two corrections. First, the real difference is **six bytes, not sixteen**: January
|
||
ends at 0x63 and ours at 0x5d, and 112 against 96 is only section alignment
|
||
rounding those up. Reading a size delta without checking where the code actually
|
||
ends invents ten bytes that were never there.
|
||
|
||
Second, all six are the clearing loop:
|
||
|
||
```
|
||
JANUARY mov ecx,0x200 / jmp / lea ecx,[ecx] / mov [eax],dl / add eax,0x40 / dec ecx / jne
|
||
OURS mov [eax],cl / add eax,0x40 / cmp eax,0x859f5 / jl
|
||
```
|
||
|
||
January keeps a separate downcounting counter and pays for a rotated,
|
||
0x40-aligned loop head; MSVC strength-reduced ours into a pointer compared
|
||
against the end address. So the question is what source form keeps the count
|
||
live, which is a source question and not yet answered -- the plain
|
||
`for (index = 0; index < NUMBER_OF_AI_DEBUG_ACTOR_RECORDS; index++)` we have does
|
||
not.
|
||
|
||
## ai_debug_render is the dispatch table, and it names thirteen statics
|
||
|
||
`ai_debug_render` (336 b) cannot be written as a pair with anything: it calls
|
||
thirteen absent statics, roughly 9,000 bytes of the 13,552 still missing. But
|
||
every call is guarded by an already-named `ai_debug` flag, so reading it assigns
|
||
a purpose to each anonymous function:
|
||
|
||
| gate | offset | static |
|
||
|------|--------|--------|
|
||
| `select_this_actor` | 0x01 | `code_00039e10` (112) |
|
||
| `render_lineoffire` | 0xA9 | `code_00037af0` (208) |
|
||
| `render_lineofsight` | 0xAA | `code_00037fa0` (304) |
|
||
| `render_ballistic_lineoffire` | 0xAB | `code_00037bc0` (272) |
|
||
| `selected_squad_index != NONE` | 0x34 | `code_00041220` (2336) |
|
||
| debug path query enable | 0x3C | `code_0003af00` (144) |
|
||
| `render_paths_failed` | 0xDB | `code_00041120` (176) |
|
||
| `render_aiming_validity` | 0xC8 | `code_000383d0` (512) |
|
||
| `render_all_actors` (passing `render_inactive_actors` in dl) | 0xA6/0xA7 | `code_000411d0` (80) |
|
||
| `render_speech \|\| 0x93 \|\| render_dialogue_variants` | 0xC9/0x93/0xD3 | `code_00039e80` (1120) |
|
||
| `render_idle_look` | 0xB6 | `code_00038de0` (336) |
|
||
| `render_spatial_effects` | 0xCC | `code_00038f30` (304) |
|
||
| `render_vehicles_enterable` | 0xD1 | `code_0003a2e0` (1152), tail call |
|
||
|
||
It also opens with three things worth keeping: it early-outs on `ai_globals+1`;
|
||
it advances a 1000-tick counter at `ai_debug.last_render_id` by `inc` then
|
||
`idiv 0x3e8`, storing the remainder; and it refreshes `selected_squad_index`
|
||
from the selected actor's `+0x34` through `datum_get(actor_data, ...)`, which is
|
||
the same field `ai_debug_teleport_to` reads off a player datum.
|
||
|
||
### What this means for sequencing
|
||
|
||
The earlier suggestion to write `ai_debug_render` together with `code_000411d0`
|
||
was wrong -- it is a thirteen-way dependency, not a pair. Two orders work:
|
||
|
||
1. Write the statics bottom-up and `ai_debug_render` last. Nothing is wasted and
|
||
every function is verified as it lands, but no section scores until its
|
||
callees exist.
|
||
2. Write `ai_debug_render` first against stub statics. The dispatcher's own bytes
|
||
can then be checked immediately, at the cost of thirteen NonMatching sections
|
||
standing in the tree until they are filled.
|
||
|
||
Order 1 is consistent with how this object has been worked so far. The cheapest
|
||
real entry is `code_000411d0` at 80 bytes, whose body is already decoded: it
|
||
iterates actors with `actor_iterator_new(&iterator, !render_inactive)` and calls
|
||
`ai_debug_render_actor(index, index == selected_actor_index, NULL)` per actor.
|
||
|
||
## Unreferenced statics are not emitted (measured)
|
||
|
||
Adding `static void code_000411d0(boolean)` with a trivial body and no caller
|
||
produces **no section at all** -- checked by building and looking for the owner
|
||
symbol, not assumed. So none of the thirteen render statics can be scored before
|
||
`ai_debug_render` exists to call them, which is the same coupling that forced the
|
||
lineofsight trio to be written as one unit.
|
||
|
||
### Prerequisites for the dispatcher-first order
|
||
|
||
`ai_debug_render` needs three externals that this tree does not yet declare, all
|
||
of them visible in its first 0x40 bytes:
|
||
|
||
* **`ai_globals`** -- a pointer; the function early-outs on a boolean at `+1`.
|
||
* **`global_ai_debug_string_position`** -- a `short` global, written from
|
||
`rasterizer_globals->+0x10` minus 0x14.
|
||
* the **`rasterizer_globals` instance** -- `rasterizer.h` defines
|
||
`struct rasterizer_globals_definition` but the pointer itself is undeclared here.
|
||
|
||
It also needs two more `ai_debug` fields named -- the gate at 0x3C, which is the
|
||
same one `ai_debug_update` uses, and the third speech-related flag at 0x93 -- and
|
||
the `actor_datum` field at `+0x34`, which is where `selected_squad_index` is
|
||
refreshed from and is the same offset `ai_debug_teleport_to` reads off a player
|
||
datum.
|
||
|
||
So the dispatcher-first order is: declare those three externals, name the two
|
||
flags and the actor field, add thirteen stub statics, then write
|
||
`ai_debug_render` against them. The dispatcher's own 336 bytes become checkable
|
||
immediately; each stub becomes checkable as it is filled.
|
||
|
||
## The two change_selected functions, and where their types came from
|
||
|
||
Both byte-exact on the first compiled attempt. Object **31/60, 4000 bytes ->
|
||
33/60, 4640 bytes**. Neither needed a rewrite of shape; both needed types that
|
||
were already provable from objects in the tree.
|
||
|
||
### `ai_debug_change_selected_encounter` (336 b)
|
||
|
||
It does **not** use an actor iterator at all -- it steps the encounter data array
|
||
with `data_next_index` / `data_prev_index`, which already exist. The previous
|
||
plan had it blocked behind the iterator work; that was wrong.
|
||
|
||
Everything it needs is confirmed by two independent offsets landing on named
|
||
fields of `struct encounter_datum` as the tree already declares it:
|
||
|
||
* `[edi+0x0d]` selects `"active"` / `"inactive"` -- that is `active`, at 0x0d.
|
||
* `[edi+0x2a]` is printed as `(%d actors)` -- that is `current_count`, at 0x2a.
|
||
|
||
The scenario side is equally pinned: element size `0xb0` matches
|
||
`sizeof(struct encounter_definition)` exactly (0x80 of scalars plus four
|
||
12-byte `tag_block`s), the block is at scenario `+0x42c` which is
|
||
`ai_encounters`, `[esi+0x7e]` is `runtime_structure_bsp_reference_index`, and
|
||
`[esi+0x20]` is `flags`.
|
||
|
||
Two flag bits are named, and only two. Bit 5 gates a literal `"3d-positions"`
|
||
and bit 6 selects `"manual"` over `"auto"` for a bsp index, so
|
||
`_encounter_3d_firing_positions_bit` and
|
||
`_encounter_manual_structure_bsp_index_bit` are read directly off the strings.
|
||
The other six bits of that word are not evidenced by this object and are left
|
||
unnamed rather than invented.
|
||
|
||
**The only thing that took a second attempt was branch polarity.** Written as
|
||
`if (encounter) { ... } else { "no more encounters" }` the body was byte-identical
|
||
from offset 0 to 0x51 and then inverted, 352 bytes against 336. January puts the
|
||
failure case in the fallthrough, so the source is
|
||
`if (!encounter) { fail } else { report }`. Flipping it closed the function
|
||
exactly -- the 16 bytes were the duplicated epilogue on the longer arm.
|
||
|
||
### The iterator struct came out of `encounters.obj`, not out of guesswork
|
||
|
||
`encounter_actor_iterator_new`, `_next` and `_prev` are undefined externals in
|
||
`ai_debug.obj`, but they are **defined** in `build/split/source/ai/encounters.obj`
|
||
(80, 64 and 128 bytes). Dumping them settles the whole interface with no
|
||
inference at all:
|
||
|
||
```c
|
||
struct encounter_actor_iterator
|
||
{
|
||
long encounter_index; // +0
|
||
long actor_index; // +4
|
||
long next_actor_index; // +8
|
||
};
|
||
```
|
||
|
||
* `new` stores the index at +0, writes `NONE` to +4, then seeds +8 from
|
||
`encounter_get(index)->first_actor_index` -- offset `0x14`, which is exactly
|
||
where `first_actor_index` sits in the tree's `struct encounter_datum`. When the
|
||
index is `NONE` it seeds +8 from `ai_globals+8` instead.
|
||
* `next` copies +8 into +4, returns `NULL` if that is `NONE`, otherwise returns
|
||
`datum_get(actor_data, index)` and refreshes +8 from the actor's `+0x2c`.
|
||
* Both `next` and `prev` return the **actor pointer**, not a boolean: the failure
|
||
paths `xor eax,eax` and the success path leaves the `datum_get` result in `eax`.
|
||
|
||
So all three are declared returning `struct actor *`. Worth stating as method:
|
||
when a callee is missing, check whether some other split object defines it before
|
||
reasoning about its signature from call sites. Here it converted three open
|
||
questions into three readings.
|
||
|
||
Their bodies are now fully decoded and `encounters.obj` stands at 1.4%, so
|
||
writing the three of them there is 272 bytes of easy progress -- left alone
|
||
because it is a different object.
|
||
|
||
### `ai_debug_change_selected_actor` (304 b)
|
||
|
||
With the iterator declared this is a direct transcription, and it was exact
|
||
first try. Two details are worth keeping:
|
||
|
||
* The counter is a `short`. It is held in `esi` and used as `movsx edx, si` /
|
||
`inc edx`, so `console_printf(..., actor_number+1, ...)`. A `long` would not
|
||
sign-extend.
|
||
* The search loop is a plain rotated `while`: January calls `next` once before
|
||
the loop head and again at the bottom, with `inc esi` between the
|
||
`actor_index` test and the second call. That is what
|
||
`while (next(&iterator)) { if (match) break; n++; }` compiles to -- no special
|
||
form needed.
|
||
|
||
Both `console_printf` calls in the failure arms push a register that MSVC knows
|
||
holds zero rather than an immediate `0`; that is a codegen consequence of
|
||
`FALSE`, not a source difference.
|
||
|
||
## The render dispatcher: prerequisites all confirmed, stubs do not work
|
||
|
||
`ai_debug_render` was written dispatcher-first against thirteen empty statics.
|
||
The result settles two things at once.
|
||
|
||
### The three externals and both flag blocks are right
|
||
|
||
The first `0x4b` bytes of `ai_debug_render` are **byte-identical to January**,
|
||
which validates every prerequisite in one build:
|
||
|
||
* `ai_globals->initialized_for_new_map` -- `ai_globals` is a pointer to a
|
||
`0x8DC` game-state block (`ai_initialize` mallocs exactly that). `+0` is
|
||
`ai_active`, named by `ai_globals_ai_active` in `ai.obj`; `+1` is written only
|
||
by `ai.c` and cleared by `ai_dispose_from_old_map` after everything is
|
||
disposed, which is what names it.
|
||
* `global_ai_debug_string_position = rasterizer_globals.frame_bounds.y1 - 20`.
|
||
The field at `rasterizer_globals+0x0C` is a `rectangle2d`, proved by `main.obj`
|
||
computing `[0x10]-[0x0c]` and `[0x12]-[0x0e]` and using those to subdivide a
|
||
screen -- `y1-y0` and `x1-x0` on the tree's `{y0,x0,y1,x1}` layout. The
|
||
`mov ecx, dword ptr [...+0x10]` for a `short` field is MSVC widening a 16-bit
|
||
load whose result is truncated back to 16 bits anyway; it is not evidence of a
|
||
`long`.
|
||
* `global_ai_debug_string_position` is an **undefined external** in
|
||
`ai_debug.obj`, as are `ai_globals` and `rasterizer_globals`, so only the
|
||
declaration belongs here.
|
||
* `ai_debug.selected_squad_index = actor_get(...)->meta.encounter_index`. The
|
||
actor field at `+0x34` needed no new name: `struct actor_datum` puts `meta` at
|
||
`0x04` (the `short identifier` is followed by 2 bytes of padding because
|
||
`actor_meta_data` is 4-aligned), and `meta.encounter_index` is at `0x30`
|
||
within it. The same shift independently explains the iterators' `actor+0x2c`
|
||
as `meta.next_actor_index`.
|
||
|
||
### The `ai_debug` flag names came from the script global table
|
||
|
||
`hs_globals_external.c` names every byte of both unknown blocks, so no guessing
|
||
was needed and none was done:
|
||
|
||
* `__unknown3C[24]` is the debug pathfinding query: `path`, `path_start_freeze`,
|
||
`path_end_freeze`, `path_flood`, `path_maximum_radius`, `path_attractor`,
|
||
`path_attractor_radius`, `path_attractor_weight`, `path_accept_radius`. The
|
||
declared fields sum to exactly 24 bytes with natural alignment.
|
||
* `__unknown85[32]` is the print-flag block, `communication_random_disabled`
|
||
through `print_uncovering`, exactly 32 bytes. The dispatcher's third
|
||
speech-related gate at `0x93` is `print_speech`.
|
||
* The gate at `0xA5` that the earlier dispatch table missed is just
|
||
`ai_debug.render`, already named.
|
||
|
||
Four `offsetof` asserts now pin `path` at `0x3C`, `print_speech` at `0x93`,
|
||
`render` at `0xA5` and `last_render_id` at `0xF4`.
|
||
|
||
### Empty stubs are inlined away -- measured
|
||
|
||
The earlier note said unreferenced statics are not emitted. The stronger fact is
|
||
that a **referenced empty static is inlined and eliminated**: with thirteen
|
||
`{ return; }` bodies the dispatcher came out 128 bytes against 336 and *every*
|
||
`call` relocation was gone, including the `code_00041220` gate whose condition
|
||
was folded away with it.
|
||
|
||
So the dispatcher-first order does not actually work under `/O2`. Each static
|
||
has to carry a real body before `ai_debug_render` can be scored, which is the
|
||
log's "order 1" after all. The dispatcher is written and its head is verified;
|
||
it will close as the statics land.
|
||
|
||
## `code_000411d0` and `code_00041120`, and the actor iterator
|
||
|
||
Both byte-exact. Object **33/60, 4640 bytes -> 35/60, 4896 bytes**.
|
||
|
||
`struct actor_iterator` came out of `encounters.obj` the same way the encounter
|
||
iterator did -- both `actor_iterator_new` and `actor_iterator_next` are defined
|
||
there:
|
||
|
||
```c
|
||
struct actor_iterator
|
||
{
|
||
struct data_iterator encounter_iterator; // +0x00, data_iterator_new(.., encounter_data)
|
||
boolean encounterless_actors_done; // +0x10
|
||
boolean active_only; // +0x11, the parameter
|
||
word pad;
|
||
long actor_index; // +0x14
|
||
long next_actor_index; // +0x18
|
||
};
|
||
```
|
||
|
||
`0x1C` total, matching `code_000411d0`'s `sub esp, 0x1c`, and `+0x14` is the
|
||
`[ebp-8]` the caller reads. `next` walks encounters through the data iterator,
|
||
skipping inactive ones when `active_only`, then falls through to the
|
||
encounterless list at `ai_globals+8` once.
|
||
|
||
### Two source questions answered, both previously parked
|
||
|
||
`code_00041120` reached January's exact size immediately but differed in two
|
||
places, and both had been recorded elsewhere in this log as compiler choices.
|
||
Neither was.
|
||
|
||
**The loop counter is `short`, not `long`.** With `long index` MSVC strength-
|
||
reduces the whole loop to a byte offset compared against the end constant
|
||
(`cmp edi, 0x394f80 / jl`). With `short index` it keeps a separate downcounter
|
||
in `ebx` exactly as January does (`mov ebx, 0x20 ... dec ebx / jne`), and the
|
||
entire rest of the function fell into place with that one change. This is the
|
||
same shape flagged as unanswered for `ai_debug_select_actor`'s clearing loop --
|
||
"what source form keeps the count live" -- and the answer is the width of the
|
||
index variable.
|
||
|
||
**The stack base is `point_from_line3d(..., 1.f, ...)`, not `add_vectors3d`.**
|
||
January loads the vector component first:
|
||
|
||
```
|
||
mov eax, [_global_up3d]
|
||
fld dword ptr [eax] ; up.i
|
||
fadd dword ptr [esi + 0x28] ; + start_point.x
|
||
```
|
||
|
||
Every spelling of an addition produces the reverse for the *first* component
|
||
only -- `add_vectors3d(global_up3d, ..)`, explicit per-component assignments,
|
||
and the nested-call form all give `fld [esi+0x28] / fadd [eax]`, because MSVC
|
||
hides the latency of the freshly loaded pointer. Three attempts at rewording the
|
||
addition changed nothing, which is the tell that the operation itself is wrong.
|
||
`point_from_line3d` computes `(v->i*t) + p->x`, so the *vector* term is loaded
|
||
first, and MSVC folds the `*1.0f` away entirely. Same instruction count, correct
|
||
operand order, function exact.
|
||
|
||
Worth generalising: when several spellings of an expression all produce the same
|
||
wrong code, stop rewording it and ask whether a different helper is being
|
||
called.
|
||
|
||
## `code_00037af0`: the operand order of a two-global add
|
||
|
||
Byte-exact. Object **35/60, 4896 -> 36/60, 5104 bytes**.
|
||
|
||
Every field it touches was already named: `lineoffire_valid` (`0xF8`),
|
||
`lineoffire_success` (`0xF9`), `lineoffire_start` (`0xFC`), `lineoffire_end`
|
||
(`0x108`), `lineoffire_pill_count` (`0x114`) and the three parallel pill arrays
|
||
at `0x118`, `0x128`, `0x1E8`, `0x2A8`. The `lea edx, [edi + 0xc0]` inside the
|
||
pill loop is `pill_end[index]` reached from `pill_start[index]`, which confirms
|
||
both array bases independently: `0x128 + 16*12 = 0x1E8`.
|
||
|
||
The only thing that took a second attempt was the sum
|
||
`lineoffire_start + lineoffire_end`. January emits
|
||
`fld [lineoffire_start] / fadd [lineoffire_end]`;
|
||
`add_vectors3d(&lineoffire_start, &lineoffire_end, &out)` emits the reverse for
|
||
**all three** components. Swapping the two source arguments gives January
|
||
exactly.
|
||
|
||
So MSVC's operand order for `a->i + b->i` where both are absolute globals is
|
||
`fld b; fadd a` -- the second argument is loaded first. That is the opposite of
|
||
the `code_00041120` case, where the deciding factor was the multiply inside
|
||
`point_from_line3d` rather than the argument order. Both are now measured; the
|
||
rule is that the emitted `fld` operand has to be read off the target and matched,
|
||
not reasoned about from the source order.
|
||
|
||
Note also that `lineoffire_end` and `lineoffire_pill_end` are **vectors**, not
|
||
points: `render_debug_pill(immediate, base, height, width, color)` takes a
|
||
`real_vector3d const *height`, and the line is drawn from `lineoffire_start` to
|
||
`start+end`. The names are the tree's existing ones and were left alone.
|
||
|
||
## `code_00037fa0`: exact size, one strength-reduction anchor apart
|
||
|
||
304 bytes against January's 304, with **21 differing bytes in one 43-byte
|
||
window** (`0xDD`-`0x107`). Everything else -- the thirteen-entry local colour
|
||
table, both loops, both `MIN`s, every relocation -- is byte-identical.
|
||
|
||
### What it is
|
||
|
||
A local `real_argb_color const **colors[13]`, thirteen stores of the *addresses
|
||
of the colour pointer globals* (`&global_real_argb_black` ... `&global_real_argb_white`),
|
||
which is why each use is a double indirection: `mov ecx,[ebp+eax*4-0x34]` then
|
||
`mov edx,[ecx]`. Thirteen pointers is exactly the `0x34` frame. Then a pass over
|
||
`lineofsight_point` labelling each point with its reference count, and a pass
|
||
over `lineofsight_pair` drawing a line per pair, both colouring by
|
||
`MIN(reference_count, 12)`. `MIN` in `cseries.h` is `((a)>(b)?(b):(a))`, which
|
||
is precisely January's `cmp / jle / mov 12`.
|
||
|
||
The pair stride confirms the tree's `struct ai_debug_lineofsight_pair` at 6
|
||
bytes, and `lineofsight_point` at `0x2F0` with 16384 elements places
|
||
`lineofsight_point_reference_count` at `0x302F0` and `lineofsight_pair_count` at
|
||
`0x402F0`, all of which the code addresses directly.
|
||
|
||
### The two forms pull against each other
|
||
|
||
The residual is a single question with two half-answers, and it is worth stating
|
||
precisely rather than calling it a tie:
|
||
|
||
* Written as `MIN(pair->reference_count, 12)` **inside the call**, MSVC anchors
|
||
the strength-reduced pair pointer at `&pair->reference_count` (`0x402F8`),
|
||
exactly like January -- but compares in **16 bits** (`mov ax` / `cmp ax`),
|
||
which costs a second arm and a `jmp`, +16 bytes.
|
||
* Assigned to a **`long` local** first, the compare is 32-bit (`movsx eax` /
|
||
`cmp eax`) and the size is exact -- but the anchor moves to
|
||
`&pair->end_index` (`0x402F6`), so all three displacements shift.
|
||
|
||
`12L` was tried as a diagnostic and changes nothing: the narrowing is not driven
|
||
by the literal's type, it is driven by whether the value passes through a 32-bit
|
||
store. So January's source has the value in something 32-bit *and* keeps the
|
||
pointer anchored on `reference_count`, and no arrangement of a `long` local
|
||
found so far does both. Five were measured: local initialised in the declaration,
|
||
local assigned as a statement, local hoisted to function scope, no `pair` local
|
||
with the array subscripted three times, and the direct expression.
|
||
|
||
The same 32-bit-store requirement is what fixed the *first* loop, where the
|
||
value additionally has to be reloaded rather than held across the `sprintf`
|
||
call -- declaring `long reference_count;` uninitialised and assigning it after
|
||
the `sprintf` gives January's two independent `movsx` loads and made that whole
|
||
loop exact.
|
||
|
||
Left NonMatching. The specific open question is: what source form gives a
|
||
32-bit `reference_count` while leaving the pair pointer anchored on the
|
||
`reference_count` member?
|
||
|
||
## The 0x4C2F4 block, decoded
|
||
|
||
`__unknown4C2F4[200844]` is now structure rather than filler. Object
|
||
**36/60, 5104 bytes -> 38/60, 5520**; `code_00037bc0` (272) and `code_0003af00`
|
||
(144) are both byte-exact, each on the first compile.
|
||
|
||
Only **three** functions in the whole object reference the block --
|
||
`ai_debug_update`, `code_00037bc0` and `code_0003af00` -- so it is derivable
|
||
from them alone. Scanning every `.text` section's `_ai_debug` relocations and
|
||
decoding the displacement gives 18, 11 and 10 distinct offsets respectively, and
|
||
those 39 offsets fix the whole layout.
|
||
|
||
### The boundary that proves itself
|
||
|
||
`0x60904 + sizeof(struct path_debug_storage)` = `0x60904 + 0x1CA7C` = **`0x7D380`**,
|
||
which is `field_7D380`, the next already-named field. So the block *ends* with
|
||
one whole `path_debug_storage`. Two independent things confirm the base:
|
||
|
||
* `code_0003af00` passes `0x6092C` to `ai_debug_drawstack_setup` and `0x60904` to
|
||
`ai_debug_render_path_storage` -- and `0x60904 + 0x28` is
|
||
`path_state.input.start_point`, exactly the pair `code_00041120` uses on the
|
||
same type.
|
||
* `ai_debug_update` writes `0x60904 = NONE`, `0x60908 = game_time_get()` and
|
||
`0x60910 = TRUE`, which are `actor_index`, `path_time` and `valid` at +0, +4
|
||
and +0xC.
|
||
|
||
### The ballistic block mirrors the lineoffire block
|
||
|
||
`0x4C2F4` is the ballistic line-of-fire record, field for field the same shape as
|
||
`lineoffire_*` at `0xF8`, minus the `pill_hit` array:
|
||
|
||
| offset | field |
|
||
|---|---|
|
||
| 0x4C2F4 | `ballistic_lineoffire_valid` |
|
||
| 0x4C2F5 | `ballistic_lineoffire_success` |
|
||
| 0x4C2F8 | `ballistic_lineoffire_start` (`render_debug_point`, 0.1f) |
|
||
| 0x4C304 | `ballistic_lineoffire_vector` (`render_debug_vector`, 1.0f) |
|
||
| 0x4C310 | `ballistic_lineoffire_pill_count` |
|
||
| 0x4C314 | `pill_start[16]` |
|
||
| 0x4C3D4 | `pill_end[16]` |
|
||
| 0x4C494 | `pill_radius[16]` |
|
||
| 0x4C4D4 | `ballistic_lineoffire_point_count` |
|
||
| 0x4C4D8 | `ballistic_lineoffire_point[]` |
|
||
|
||
The three pill spacings check independently -- `0xC0`, `0xC0`, `0x40` for 16
|
||
elements of 12, 12 and 4 bytes -- and reuse `MAXIMUM_AI_DEBUG_LINEOFFIRE_PILLS`.
|
||
|
||
The trailing array is a **polyline**: the second loop runs to `count-1` and calls
|
||
`render_debug_line(&point[index], &point[index+1], ...)`, which is why the two
|
||
relocations are 12 bytes apart. It colours the final segment orange
|
||
(`index == count-2`) unless the trace succeeded, in which case every segment is
|
||
green. Its bound is **not** provable -- nothing references the end of the array --
|
||
so it is declared `[64]` with the leftover 16 bytes left explicitly unknown and
|
||
a comment saying so.
|
||
|
||
Both loops use a `short` index (`movsx eax, si`), consistent with the rule
|
||
recovered in `code_00041120`.
|
||
|
||
### `0x4C81C` is a whole `struct path_state`
|
||
|
||
The single most useful line in `ai_debug_update` is
|
||
|
||
```
|
||
0220 push 0x60904 / push 0x4c81c / push &input / call _path_state_new
|
||
```
|
||
|
||
which types `0x4C81C` outright. Everything the earlier pass had recorded as
|
||
anonymous scalars then falls out of `path.h`:
|
||
|
||
* `0x4C868` = `+0x4C` = `path_state.destination_valid`
|
||
* `0x4C89C` = `+0x80` = `path_state.node_count`, and the bare `cmp ax, 0x400`
|
||
in `code_0003af00` is `>= PATH_NODE_LIST_SIZE`, which `path.h` defines as
|
||
`0x400`.
|
||
|
||
So the colour ladder in `code_0003af00` is not arbitrary: blue when no
|
||
destination is set, green when the search found nothing, yellow when the node
|
||
list is **full**, pink otherwise.
|
||
|
||
`path_state_destination(&path_state, &path_end_point, 0x4C810, ai_debug.path_accept_radius)`
|
||
then names `0x4C810` as `path_end_surface_index`, and confirms `0x50` as
|
||
`path_accept_radius` from the hs table.
|
||
|
||
### What is named and what is not
|
||
|
||
Named only where a *second* use proves it. `path_start_surface_index` and
|
||
`path_start_unit_index` are read back by `path_input_set_start` and
|
||
`path_input_new`; the `0x3D`/`0x3E` freeze flags gate the two records, which is
|
||
what makes them start and end rather than a guess. Fields written but never read
|
||
in this object keep `field_XXXXX`: `field_4C814` (stored 0), `field_4C818` (set
|
||
TRUE at the end of the update), and `field_608A8`, which is the boolean
|
||
out-parameter of `path_state_build_path` and which `code_0003af00` uses to
|
||
suppress the start-to-end line.
|
||
|
||
### Caveat on the 16,384-byte gap
|
||
|
||
Declaring `struct path_state` at `0x4C81C` leaves exactly `0x4000` bytes before
|
||
`field_608A8`. The `offsetof` asserts pin the block's *total*, and the bytes of
|
||
both functions match, so nothing here is wrong -- but `path.h` models
|
||
`path_debug_storage` with a trailing `__unknown[51676]`, so the split between
|
||
`sizeof(path_state)` and that filler is approximate and the `0x4000` may belong
|
||
inside `path_state` rather than after it. Fields before `node_count` at `+0x80`
|
||
are unaffected, which is why the code still matches.
|
||
|
||
### Verification
|
||
|
||
Five `offsetof` asserts now pin the block -- `ballistic_lineoffire_valid` at
|
||
`0x4C2F4`, `ballistic_lineoffire_point_count` at `0x4C4D4`, `path_state` at
|
||
`0x4C81C`, `path_storage` at `0x60904` and `field_7D380` at `0x7D380` -- so any
|
||
future edit that moves the layout fails to compile rather than silently
|
||
mismatching.
|
||
|
||
## `ai_debug_update`: written, path half exact, blocked on two statics
|
||
|
||
1168 bytes against January's 1168, **239 differing bytes all past `0x2F6`**. The
|
||
whole path-query half -- `0x000`-`0x2F5`, 758 bytes, 65% of the function -- is
|
||
byte-identical. **Not credited**, and it cannot be until two callees exist.
|
||
|
||
### The blocker, stated up front
|
||
|
||
The function ends `call code_000386a0; call code_00039060`. Both are
|
||
**file-local statics** -- neither symbol appears in any other split object -- so
|
||
they must be defined in this file, and an empty definition is inlined away under
|
||
`/O2`. The relocation multiset says exactly this and nothing else:
|
||
|
||
```
|
||
MISSING: _code_000386a0 x1, _code_00039060 x1
|
||
SURPLUS: (none)
|
||
```
|
||
|
||
Every other relocation of the 95 matches -- all eight path calls, both console
|
||
strings, and every `_ai_debug` displacement. So the remaining work on this
|
||
function is `code_000386a0` (496 bytes) and `code_00039060` (288), plus the
|
||
register allocation in the two editor fix-up loops.
|
||
|
||
### What the function does
|
||
|
||
Three independent sections:
|
||
|
||
1. `render_lineofsight` zeroes `lineofsight_point_count` and
|
||
`lineofsight_pair_count` -- the two stores `ai_debug_lineofsight_reset` makes,
|
||
written out rather than called.
|
||
2. `ai_debug.path` gates the whole debug pathfinding query: fill the start record
|
||
from the local player's biped unless `path_start_freeze`, fill the end record
|
||
by casting a 1000-unit `global_down3d` ray from the camera through
|
||
`collision_test_vector` unless `path_end_freeze`, then build a `path_input`,
|
||
run `path_state_new` / `path_state_find` / `path_state_build_path`, and stamp
|
||
the debug storage with `NONE`, `game_time_get()` and `valid`.
|
||
3. Two one-shot editor migrations, each gated on a flag it clears when done.
|
||
|
||
The `path_flood` flag decides whether the destination is set **before or after**
|
||
`path_state_find` -- the same call appears twice with opposite guards, which is
|
||
what a flood-fill visualisation needs.
|
||
|
||
`ai_fix_defending_guard_firing_positions` shifts every squad's
|
||
`firing_position_groups` up by one from index 2 and copies `attacking` into
|
||
`attacking_guard` -- a migration for an enum that gained a member. That is
|
||
readable straight off the four-iteration copy loop and the offsets `0x54`-`0x6C`.
|
||
`ai_fix_actor_variants` walks every squad starting location and sets a `short` at
|
||
`+0x18` to `NONE`; the string "reset the actor variant in all %d starting
|
||
locations" names that field, so `struct actor_starting_location` is declared with
|
||
`actor_variant_index` and the rest unknown.
|
||
|
||
### Four source facts recovered, each worth one measurement
|
||
|
||
* **The frame is 0x6C, not 0xC0, and scoping is what does it.** January overlaps
|
||
the `collision_result` (0x50), the `path_input` (0x48) and one 12-byte point
|
||
slot across three disjoint scopes. Declaring `input` at the top of the
|
||
`if (ai_debug.path)` block keeps it live across the camera ray and costs 84
|
||
bytes of frame. Moving it into `if (path_start_valid)`, and giving the
|
||
path-start block, the camera block and the attractor block each their own
|
||
12-byte local, reproduces January's frame exactly. This is the *scope a dying
|
||
local* rule paying off on a real function rather than a toy.
|
||
* **`field_4C814` is a `real`, not a `long`.** January emits the 10-byte
|
||
`mov dword ptr [0x4c814], 0`; ours emitted the 6-byte `mov [0x4c814], edi`,
|
||
because MSVC knew `edi` held the integer 0 and reused it. It will not do that
|
||
for a float store. Four bytes, and the only clue was the instruction length.
|
||
* **Assignment order decides which store gets the 5-byte accumulator form.**
|
||
In the start record January stores `path_start_surface_index` out of `eax`
|
||
*first*, which frees `eax` for the third point component and lets that store use
|
||
the short `A3` encoding. Writing the point first cost one byte. The end record
|
||
is the same trick with `path_end_valid` assigned before the point.
|
||
* **The default-value ternary needs the default in the fallthrough.** Written
|
||
`x!=0.f ? x : default` MSVC puts the value in the fallthrough and jumps to the
|
||
default; January is the other way round. `x==0.f ? default : x` matches. Both
|
||
arms are the same total size, so this never showed as a size delta -- it was
|
||
found by reading branch polarity, the same way the `horizontal_angle` minimum
|
||
was.
|
||
|
||
### What is still wrong, precisely
|
||
|
||
Everything from `0x2F6` -- the two fix-up loops. The signature difference is that
|
||
January materialises the subobject pointer once, `lea ebx, [eax+0x42c]`, and then
|
||
reads the count through it (`cmp [ebx], edi`), spilling it to `[ebp-0xC]` because
|
||
the inner copy loop clobbers `ebx`. Ours keeps the scenario pointer and computes
|
||
`[ebx+0x42c]` lazily, so it never needs the spill and allocates differently. The
|
||
loop counter widths are already right -- `short` for all three indices, `long` for
|
||
the count, confirmed by the `movsx` on each back-edge.
|
||
|
||
### Supporting declarations added
|
||
|
||
`ai_profile_globals` moved from `ai_profile.c` to `ai_profile.h` so `ai_debug.c`
|
||
can reach the counter at `0xB34`. `map_data` became a named sub-struct rather than
|
||
a flat `byte[0xEE0]` so the counter is a real `short` field instead of a punned
|
||
offset -- `sizeof` is unchanged, asserted, and `ai_profile.obj` is unmoved at
|
||
9/23. The per-map block is an array of `0x88`-byte records after a `0x110`
|
||
header, which is where `0xB34` falls, but only the one counter is named.
|
||
|
||
Eight `path_*` prototypes were added to `path.h` with argument order read off the
|
||
call sites. Every parameter is dword-sized, so the middle parameter *names* of
|
||
`path_input_new` are not pinned by the object; that is flagged in the header.
|
||
|
||
`__unknown02[16]` in `ai_debug_state` is now fully named -- the hs script global
|
||
table covers exactly those sixteen bytes, `ai_fix_defending_guard_firing_positions`
|
||
through `ai_debug_oversteer_disable`.
|
||
|
||
## `code_00039060` and `ai_debug_get_last_path`
|
||
|
||
Object **38/60, 5520 bytes -> 40/60, 5904**.
|
||
|
||
### `code_00039060` (288 b), exact on the first compile
|
||
|
||
Duplicate-path culling over `actor_path_debug_array`. For each valid *failed*
|
||
path it scans the later entries for one belonging to the same actor whose start
|
||
point is within 0.25 and whose destination agrees, and drops whichever of the two
|
||
is older. Every offset came free from the `path_state` typing done earlier:
|
||
`+0x28` is `path_state.input.start_point`, `+0x60` is
|
||
`path_state.destination_valid`, `+0x64` is `path_state.destination.point`.
|
||
|
||
One thing worth recording because it looks like evidence and is not. January
|
||
**inlines** `distance_squared3d` at the start-point test -- a run of
|
||
`fld/fsub` leaving three values on the x87 stack, cleaned by three `fstp st(0)`
|
||
-- and emits a real `call _distance_squared3d` at the destination test. That is
|
||
tempting to read as two different helpers. It is not: writing both as ordinary
|
||
calls to the one `real_math.h` `__inline` reproduces the split exactly. MSVC
|
||
declines to inline the second under the register pressure of the inner loop.
|
||
Frame `0xC` with no vector slot confirms the first one really is register-only.
|
||
|
||
Loop shape came free too: `cmp bx, 0x20` and `movsx esi, bx` on both counters is
|
||
the `short` index rule, and the outer loop carries the separate downcounter that
|
||
rule predicts.
|
||
|
||
### `ai_debug_get_last_path` (96 b): one character
|
||
|
||
This function was already in the file, already logically correct, and already the
|
||
right size -- 96 against 96, differing only from offset `0x40`. The whole
|
||
difference was the final ternary:
|
||
|
||
```c
|
||
return found_path_index!=NONE ? &actor_path_debug_array[found_path_index] : NULL; // 96 bytes, wrong
|
||
return found_path_index==NONE ? NULL : &actor_path_debug_array[found_path_index]; // exact
|
||
```
|
||
|
||
### The rule, now measured twice
|
||
|
||
**MSVC puts a ternary's true arm in the fallthrough.** So read which arm January
|
||
falls through to and make that the true arm. `a!=b ? x : y` and `a==b ? y : x`
|
||
are the same program and different code.
|
||
|
||
Both of today's instances were the same rule seen from different directions: the
|
||
`path_attractor_weight` default in `ai_debug_update`, where January falls through
|
||
to the *default*, and this one, where it falls through to *NULL*. Neither showed
|
||
up as a size delta -- both arms are the same length in each case -- so neither was
|
||
findable by any size or relocation diagnostic. Only branch polarity finds it.
|
||
|
||
Added to `docs/house_rules.md` under Control flow.
|
||
|
||
## `code_000386a0` (496 b): decoded, not written
|
||
|
||
Recording the analysis so the next attempt starts here. This is the last blocker
|
||
on `ai_debug_update`, together with nothing else -- `code_00039060` is now done.
|
||
|
||
It is the debug speech driver, gated on `speak_valid` (`0x85B20`) with the unit
|
||
in `speak_unit_index` (`0x85B24`), all already named in the tree.
|
||
|
||
```
|
||
0x00 speak_valid and speak_unit_index != NONE
|
||
0x21 unit_get(unit_index) (mask 3)
|
||
0x34 bail if unit->+0xB6 has bit 2 set
|
||
0x41 bail if unit->+0x338 (short) is non-zero
|
||
0x4f if field_85B28 > 0, decrement it; bail while it is still non-zero
|
||
0x6b range-check speak_vocalization_type against 0 and 0xD1
|
||
0x84 unit_test_speech(unit_index, 3, 0, 0, 0, &vocalization, &tag_index)
|
||
0xb1 if the result >= 2:
|
||
csmemset(&packet, 0, 0x30)
|
||
packet: +0 = 4 (short), +2 = vocalization, +4 = tag_index, +0xC = 0xF
|
||
ai_communication_packet_new(&packet + 0x10)
|
||
unit_speak(unit_index, result, &packet)
|
||
0xf8 if tag_index != NONE, tag_get_name, then strstr(name, "conditional")
|
||
and strchr(that, '\\')+1 to trim to the leaf name; else "<none>"
|
||
0x138 console_printf(FALSE, "%s: %s", dialogue_get_vocalization_name(type, 0), name)
|
||
0x159 if field_85B21, advance to the next vocalization whose name is not
|
||
"unused", resetting field_85B28 to 0xF, stopping at 0xD1
|
||
0x1cf "speech done", then speak_valid = FALSE
|
||
```
|
||
|
||
What it needs that the tree does not have: prototypes for `unit_test_speech`,
|
||
`unit_speak`, `ai_communication_packet_new`, `dialogue_get_vocalization_name`
|
||
and `tag_get_name`; the `unit_datum` fields at `0xB6` and `0x338`; and a 0x30-byte
|
||
communication packet whose first four fields are visible above but whose tail is
|
||
not. `ai.h`'s `struct ai_information_packet` is about 0x1C and does **not** fit,
|
||
so the packet here is a different, larger type. That is the piece to establish
|
||
first, and it should be established from another object that builds one rather
|
||
than guessed from this call site alone.
|
||
|
||
## Sweeping for flipped arms, and what it found in `render_actor`
|
||
|
||
The ternary rule recovered from `ai_debug_get_last_path` is mechanically
|
||
detectable, so rather than eyeball for more instances it was turned into a
|
||
scanner: align the two instruction streams with branch and call targets erased
|
||
so accumulated drift does not desynchronize them, then report any 1:1
|
||
substitution of opposite-polarity conditional branches.
|
||
|
||
Validated both ways before use, per §7 -- silent on three byte-exact functions,
|
||
and fires on exactly one site when `ai_debug_get_last_path`'s ternary is flipped
|
||
back. A clean object and a broken scanner look the same otherwise.
|
||
|
||
It now lives at `tools/inverted_branch_scan.py`, takes two object files rather
|
||
than needing the build harness, and is object-agnostic.
|
||
|
||
### Two hits, both real source errors
|
||
|
||
**`0x13D` -- a genuine logic inversion, not a spelling.** The unit-highlighting
|
||
block read
|
||
|
||
```c
|
||
if (!actor->meta.swarm) { ...iterate swarm->unit_indices... }
|
||
else { ai_debug_highlight_unit(actor->meta.unit_index, ...); }
|
||
```
|
||
|
||
January's `je 0x1a4` puts the **swarm-set** case in the fallthrough, and `0x1a4`
|
||
-- the swarm-clear arm -- highlights the single `meta.unit_index`. So ours had
|
||
the condition negated: it iterated a swarm's units for non-swarm actors and
|
||
highlighted a swarm actor's `unit_index` as if it were a lone biped. That is a
|
||
behavioural bug that happened to survive because the function is not credited.
|
||
|
||
**`0x1B8E` -- the team name ternary.** `actor->meta.team_index!=NONE ?
|
||
teams[...] : "none"` against January falling through to `"none"`. Corrected to
|
||
`==NONE ? "none" : teams[...]`, the same one-character shape as
|
||
`ai_debug_get_last_path`.
|
||
|
||
Neither changed the section size, the frame or the relocation count -- both arms
|
||
are the same length in each case -- which is exactly why nothing else had caught
|
||
them. `render_actor` stays 24,960 against 24,976 with the frame at `0x81C`.
|
||
|
||
### The sweep is exhausted
|
||
|
||
After both fixes the scanner reports **0 hits across all 53 functions of
|
||
`ai_debug.obj` that exist in our build**, and 0 across 779 shared functions in
|
||
ten other objects -- `hs` (441), `objects` (121), `players` (68), `main` (65),
|
||
`vehicles` (39), `actor_perception`, `actors`, `encounters`,
|
||
`ai_communication`, `ui_widget`. `ui_widget_event_handler_functions` at 100/100
|
||
reports 0 as the control it should.
|
||
|
||
So this residual class is closed for the reachable corpus. It is worth re-running
|
||
whenever a new function is written, which is cheap, rather than when a function
|
||
is already stuck.
|
||
|
||
## `code_000386a0` byte-exact: the packet type was already in the tree
|
||
|
||
Object **40/60, 5904 bytes -> 41/60, 6400**.
|
||
|
||
### The packet was `struct unit_speech_item`, which already existed
|
||
|
||
The previous entry said the 0x30-byte record it builds does not fit `ai.h`'s
|
||
`ai_information_packet` and should be established from an object that constructs
|
||
one rather than guessed. Doing that found it immediately, and found that nothing
|
||
needed inventing.
|
||
|
||
`action_obey.obj` builds the same record for the same three calls:
|
||
|
||
```
|
||
csmemset(&item, 0, 0x30)
|
||
[item + 0x00] = 6 (ai_debug writes 4)
|
||
[item + 0x02] = vocalization
|
||
[item + 0x04] = tag index out of unit_test_speech
|
||
ai_communication_packet_new(&item + 0x10)
|
||
unit_speak(unit_index, result, &item)
|
||
```
|
||
|
||
Two independent constructors agreeing on `0x30` and on the `+0x10` sub-object is
|
||
the boundary-trust signal. And `units.h` already declares exactly that shape:
|
||
|
||
```c
|
||
struct unit_speech_item
|
||
{
|
||
short priority; // +0x00 -- 4 here, 6 in action_obey
|
||
short vocalization_type; // +0x02
|
||
long sound_definition_index; // +0x04
|
||
short delay_time;
|
||
short ai_notification_delay;
|
||
short pause_time; // +0x0C -- set to 15
|
||
word pad;
|
||
struct ai_information_packet ai; // +0x10, and sizeof is 0x20
|
||
};
|
||
```
|
||
|
||
`0x10 + 0x20 = 0x30`. So the field at `+0x00` is a **priority**, not a type
|
||
selector, which is why the two call sites differ.
|
||
|
||
### The compiler as an offset oracle
|
||
|
||
Two unit fields were unnamed: a byte at `+0xB6` tested against bit 2, and a short
|
||
at `+0x338`. Rather than counting struct members by hand, generate one
|
||
`char probe_N[offsetof(struct unit_datum, <member>)+1];` per member of
|
||
`_object_datum` and `_unit_datum`, compile once with the unit's real flags, and
|
||
read the symbol sizes back out of the COFF. That named both in a single build:
|
||
|
||
* `+0xB6` is `object.damage_flags`, and bit 2 is `_object_dead_bit`
|
||
* `+0x338` is `unit.speech`, whose first member is `current.priority`
|
||
|
||
So the two guards are "the unit is dead" and "the unit is already speaking". This
|
||
technique generalises to any unnamed offset in an already-declared struct and is
|
||
much faster than reading headers.
|
||
|
||
### Three residuals, three source facts
|
||
|
||
**Nothing was declared.** `unit_test_speech`, `unit_speak` and
|
||
`ai_communication_packet_new` had no prototypes anywhere, and MSVC compiled the
|
||
calls cleanly through implicit declaration -- exactly the trap §6 warns about.
|
||
The widths are readable off the target: the result is compared as `cmp si, 2`, so
|
||
it returns `short`, and `unit_speak`'s second argument is pushed straight from
|
||
`esi` with no sign extension, which only happens when that parameter is declared
|
||
`short`. With a `long` parameter MSVC emits `movsx edx, si` first. Declared in a
|
||
new `units/unit_dialogue.h` and in `ai_communication.h`.
|
||
|
||
**`name` must not be initialised at its declaration.** January coalesces
|
||
`speech_type` and `name` into `esi`, because the first is dead before the second
|
||
is born. Writing `char const *name = "<none>";` at the top overlaps their live
|
||
ranges, forces a second callee-saved register, and costs a `push edi` / `pop edi`
|
||
/ `mov edi, 0`. Assigning `"<none>"` in genuine `else` arms instead lets MSVC
|
||
merge the two stores into one block *and* reuse the register -- the dying-local
|
||
rule again, this time about an initialiser rather than a brace.
|
||
|
||
**`++separator`, not `separator+1`.** January reads the flags `inc` already set:
|
||
`inc eax / je`. Writing the condition as `separator && separator+1` produces
|
||
`inc eax / test eax,eax / je` -- MSVC will not reuse the flags of an expression
|
||
it treats as a value rather than a side effect. `separator && ++separator` does.
|
||
One byte, and it was the last one.
|
||
|
||
## `ai_debug_update` is now unblocked and clean everywhere but one region
|
||
|
||
With both callees written, `ai_debug_update` is **1168 bytes against 1168 with
|
||
all 95 relocations matching and no surplus** -- the missing-call pair is gone.
|
||
The first `0x2F6` bytes, 758 of them, are byte-exact.
|
||
|
||
The remaining 242 differing bytes are entirely the two editor fix-up loops, and
|
||
the signature is one scheduling decision at each loop's entry test:
|
||
|
||
```
|
||
January lea ebx,[eax+0x42c] ; &scenario->ai_encounters
|
||
cmp dword ptr [ebx], edi ; compare the count in memory
|
||
ours mov ebx, eax
|
||
mov eax,[ebx+0x42c] ; load the count into a register first
|
||
add ebx, 0x42c
|
||
cmp eax, edi
|
||
```
|
||
|
||
Both spill `encounters` to `[ebp-0xC]`, both re-read the count from memory on the
|
||
back edge, and the inner loop body matches. Only the order of "adjust the pointer"
|
||
against "read the count" differs at entry. Not yet resolved.
|
||
|
||
## `ai_debug_describe_actor` byte-exact
|
||
|
||
Object **41/60, 6400 bytes -> 42/60, 6832**. Size was exact at 432 on the first
|
||
compile; the only residual was the platoon `if`/`else` arm order, the same rule
|
||
as `ai_debug_get_last_path`. January falls through to the two-argument
|
||
`sprintf`, so `if (!platoon)` is the true arm.
|
||
|
||
It was worth doing early for a reason that is easy to miss: it is **not static**,
|
||
and two already-exact functions -- `ai_debug_change_selected_actor` and
|
||
`code_00041120` -- already referenced it as an undefined external and matched
|
||
anyway, because the comparator resolves relocations by symbol name. So it could
|
||
be written and credited standalone, unlike the render statics.
|
||
|
||
Four offsets were named with the probe technique rather than by reading headers:
|
||
|
||
| reference | field |
|
||
|---|---|
|
||
| `scenario + 0x204` | `object_names`, element `0x24` = `scenario_object_name` |
|
||
| `object + 0x6A` | `name_index` |
|
||
| `object_definition + 0x2C` | `model.name` -- `model` is a `tag_reference` at `0x28` |
|
||
| `encounter + 0x8C` | `platoons`, element `0xAC` = `sizeof(platoon_definition)` |
|
||
|
||
## Status after this pass
|
||
|
||
**42 of 60 functions exact, 6832 of 42992 bytes (15.9%), 346 of 398 sections.**
|
||
|
||
Twelve functions are written but not exact, and seven are absent:
|
||
|
||
| state | count | bytes |
|
||
|---|--:|--:|
|
||
| exact | 42 | 6,832 |
|
||
| written, not exact | 11 | 33,040 |
|
||
| absent | 7 | 3,120 |
|
||
|
||
Of the eleven written-but-not-exact, **seven are deliberate empty stubs** that
|
||
exist only so `ai_debug_render` has something to call -- `code_00041220` (2336),
|
||
`code_0003a2e0` (1152), `code_00039e80` (1120), `code_000383d0` (512),
|
||
`code_00038de0` (336), `code_00038f30` (304), `code_00039e10` (112). Those
|
||
5,872 bytes are unwritten work, not near-misses.
|
||
|
||
The genuine near-misses are four:
|
||
|
||
* `ai_debug_update` -- 1168/1168, all 95 relocations, first 758 bytes exact;
|
||
residual is one scheduling decision at each fix-up loop's entry test.
|
||
* `ai_debug_render_actor` -- 24,960 against 24,976, frame `0x81C` against
|
||
`0x810`.
|
||
* `ai_debug_render_path_storage` -- 512 against 816; needs `code_00037890`,
|
||
which it calls three times.
|
||
* `code_00037fa0` -- 304/304, 21 bytes, the pair-pointer anchor.
|
||
* `ai_debug_render` -- 224 against 336, blocked on the seven stubs.
|
||
|
||
### Note on `code_00037890`
|
||
|
||
Decoded but not written, because it is a **static called only from
|
||
`ai_debug_render_path_storage`**, so it cannot be scored until that function
|
||
calls it. It takes two register parameters -- `ecx` a `real_point3d const *`
|
||
start and `ebx` a `real_argb_color const *` -- plus a `short` count and a
|
||
`struct path_step *` on the stack, and draws a path as a polyline of
|
||
`render_debug_line_offset` segments with a `render_debug_tick` per node. The
|
||
`0x10` stride and the `+4` offset are `struct path_step`, which `path.h` already
|
||
declares. It should be written together with `ai_debug_render_path_storage`.
|
||
|
||
## Flag provenance addendum (2026-08-07)
|
||
|
||
The unit's configured `/QIfist` is **placebo and a hazard**. Census
|
||
(`tools/flag_provenance_census.py`): the January target contains no
|
||
float→int conversion evidence at all — no `__ftol2` reference and no
|
||
inline fist site in any of the 60 functions — so the flag is unfalsifiable
|
||
here and does nothing today: under full default flags the unit measures an
|
||
identical 42 exact of 54 shared. The hazard is prospective: the census
|
||
proves the January build used no `/QIfist` anywhere (zero qword `fistpll`
|
||
sites in 467 targets), and neighbouring ai units (`actor_combat`,
|
||
`actor_perception`, `actor_looking`) carry inline-helper conversion sites
|
||
in unwritten functions. Any conversion written into this TU under the
|
||
current config would emit unmatchable `fistpll`. Recommendation: flip the
|
||
unit's cflags to default at this lane's next session (measured cost-free)
|
||
before the render functions are attempted.
|
||
|
||
## `code_00037890` byte-exact, and the path step arrays named
|
||
|
||
Object **42/60, 6832 bytes -> 43/60, 6960**. Exact on the first compile, custom
|
||
register convention included.
|
||
|
||
### The convention came free from declaration order
|
||
|
||
This static takes **two register parameters and two stack parameters**: `ecx` a
|
||
`real_point3d const *` start, `ebx` a `real_argb_color const *`, then a `short`
|
||
count at `[ebp+8]` and a `struct path_step *` at `[ebp+0xC]`. Declaring them in
|
||
that order and letting MSVC choose reproduced `ecx`/`ebx` exactly -- the same
|
||
lesson as the lineofsight trio, where the invented convention follows the
|
||
signature and cannot be dictated.
|
||
|
||
Both loop counters are `short`, read off `test si,si` and `cmp di,[ebp+8]`.
|
||
|
||
### Three call sites fix three arrays
|
||
|
||
`ai_debug_render_path_storage` calls it three times, gated on
|
||
`render_paths_raw` / `_smoothed` / `_avoided` (`0xDD`/`0xDE`/`0xDF`), which
|
||
places three `{short count; word pad; struct path_step steps[N];}` blocks inside
|
||
`path_debug_storage`'s 51,676-byte tail:
|
||
|
||
| offset | field | extent |
|
||
|---|---|---|
|
||
| 0x140FC | `raw_step_count` | steps at 0x14100, 64 entries |
|
||
| 0x14500 | `smoothed_step_count` | steps at 0x14504, 4 entries |
|
||
| 0x14544 | `avoided_step_count` | steps at 0x14548, 4 entries |
|
||
|
||
The 64/4/4 split is asymmetric and looks wrong at first, but each extent is
|
||
pinned by the *next* referenced offset, and the run ends two bytes before
|
||
`0x1458A`, which the avoidance-segment code reads as a `short`. Three
|
||
`offsetof`/`sizeof` asserts hold the layout, and the `0x1CA7C` total is
|
||
unchanged.
|
||
|
||
`struct path_result`'s `steps[4]` is **not** these blocks -- its header is
|
||
`{boolean valid; path_destination endpoint; ...}`, so `count` would be at `+0x19`,
|
||
not `+0x00`. Different type, same element.
|
||
|
||
## `ai_debug_render_path_storage`: decoded, deliberately not finished
|
||
|
||
640 bytes against 816. The remainder is a genuine data-model job rather than a
|
||
shape problem, and it is recorded here rather than half-attempted.
|
||
|
||
What is already read off the target:
|
||
|
||
* Two adjacent **6-entry string tables** in the frame at `[ebp-0x34]` and
|
||
`[ebp-0x1C]` -- the `path_traverse_result` and `path_build_result` names, whose
|
||
enumerations `path.h` already declares. The two `match_assert`s at January
|
||
lines **3944** and **3945** carry their bounds and name both constants outright:
|
||
`NUMBER_OF_PATH_TRAVERSE_RESULTS`, `NUMBER_OF_PATH_BUILD_RESULTS`.
|
||
* The header colour is green only when *both* results are `5` (`_success`), else
|
||
red, and the label is `csprintf(temporary, "%s / %s (%d)", traverse_name,
|
||
build_name, game_time_get()-path->path_time)`.
|
||
* `[ebp-4]` is a `boolean` holding
|
||
`path->structure_bsp_index == global_structure_bsp_index_get()`, reused much
|
||
later to gate `render_debug_path`.
|
||
|
||
What it still needs, all inside the same 51 KB tail:
|
||
|
||
* `0x1458A` a `short` avoidance-segment count, `0x1458C` an array of stride
|
||
**0xC08**, and `0x175AC` an array of stride **0x1534**, indexed by
|
||
`ai_debug.render_paths_avoidance_segment` (`0xE0`).
|
||
* Prototypes for `render_debug_obstacles` and `render_debug_path`, neither
|
||
declared in the tree.
|
||
|
||
Those three strides are the work: each is a substantial structure, and guessing
|
||
one from a single indexed reference is exactly the failure this log has recorded
|
||
before. Establish them from an object that *builds* one, as the speech packet was
|
||
established from `action_obey.obj`.
|
||
|
||
## `code_00038f30` byte-exact: the spatial-effect ring, and the subobject rule both ways
|
||
|
||
Object **43/60, 6960 bytes -> 44/60, 7264**.
|
||
|
||
### The ring proves its own extent, twice
|
||
|
||
`ai_globals` carries a debug spatial-effect ring: a `short` head at `0x130`, a
|
||
`short` tail at `0x132`, and 20-byte records from `0x134`. The record is
|
||
`{short type; short field_02; real_point3d point; long time;}` -- `0x14`, which
|
||
is the `lea eax,[eax+eax*4]` then `*4` scale at the indexing site.
|
||
|
||
Two independent facts fix the count at 32, so it is not a guess:
|
||
|
||
* the index wraps with `inc cl / and ecx, 0x1f`;
|
||
* `0x134 + 32*0x14 = 0x3B4`, and `0x3B4` is the next `ai_globals` offset used
|
||
anywhere -- `ai.c` writes a byte there and `actions.c` reads it.
|
||
|
||
`field_02` is left unnamed. It is printed as `"c%d"`, which suggests a count, but
|
||
one format letter is not evidence.
|
||
|
||
### The named subobject pointer, applied in both directions
|
||
|
||
This function is a clean demonstration of `house_rules` §6's symmetric rule, and
|
||
it took both halves to close.
|
||
|
||
At exact size (304/304) the residual was purely register assignment: January
|
||
keeps the *record* in `edi` and materialises `lea esi,[edi+4]` for the point,
|
||
reading `[esi]`, `[esi+4]`, `[esi+8]` through it while `edi` stays live for
|
||
`[edi]`, `[edi+2]` and `[edi+0x10]`. Ours held the record in `esi` and addressed
|
||
the point off it.
|
||
|
||
1. **Naming the pointer** -- `real_point3d *effect_point = &effect->point;` --
|
||
produced January's `edi`/`esi` split immediately.
|
||
2. **But it then emitted the `lea` too early**, before the colour selection,
|
||
where January emits it after. Declaring the pointer without an initialiser and
|
||
assigning it just before its first use moved the `lea` to January's position
|
||
and closed the function.
|
||
|
||
So the rule is genuinely two-sided: *whether* the subobject is named decides the
|
||
register split, and *where the name is born* decides the `lea` placement. This is
|
||
the same lesson as `code_000386a0`'s `name` initialiser -- an initialiser at the
|
||
declaration is a lifetime -- reaching a different instruction.
|
||
|
||
## `code_00038280` and `code_00039e10` byte-exact
|
||
|
||
Object **45/60, 7600 bytes -> 47/60, 8048**.
|
||
|
||
`code_00038280` is the screen-pick: find the first existing local player, cast a
|
||
50-unit ray along `camera->forward` with `collision_test_vector(0x81, ...)`,
|
||
ignoring the player's own unit unless the director perspective is first-person,
|
||
and resolve the hit object to an actor. `code_00039e10` is its caller -- it
|
||
describes the picked actor, prints `"selected %s"`, selects it, and clears
|
||
`select_this_actor`.
|
||
|
||
Four field names came from the offset oracle: `unit.actor_index` at `0x1A4`,
|
||
`unit.swarm_actor_index` at `0x1A8`, `unit.driver_object_index` at `0x2D4`, and
|
||
`player_datum.unit_index` at `0x34`. `observer_result.forward` at `0x20` and
|
||
`collision_result.object_index` at `0x38` fall out of the declared structures.
|
||
|
||
### Three defects, three different classes
|
||
|
||
**A missing include is a wrong prototype.** `director_get_perspective` was not
|
||
declared, so MSVC compiled an implicit `int`-returning call: `movsx ecx, si` on
|
||
the argument and `test eax, eax` on the result where January passes `si`
|
||
directly and tests `ax`. `director.h` declares it `director_perspective` --
|
||
a `short` -- taking a `short`. Adding the include fixed both instructions and
|
||
16 bytes. This is the same trap as `unit_speak`: **MSVC compiles an undeclared
|
||
call silently, and the width shows up several instructions away.**
|
||
|
||
**The two `swarm_actor_index` ternaries are spelled differently.** The first
|
||
falls through to the `actor_index` fallback, the second falls through to the
|
||
swarm value:
|
||
|
||
```c
|
||
actor_index = unit->unit.swarm_actor_index==NONE ?
|
||
unit->unit.actor_index : unit->unit.swarm_actor_index; // first
|
||
...
|
||
actor_index = driver->unit.swarm_actor_index!=NONE ?
|
||
driver->unit.swarm_actor_index : driver->unit.actor_index; // second
|
||
```
|
||
|
||
Flipping *both* to the same spelling was wrong. The fallthrough rule is per
|
||
site, and two structurally identical expressions in one function can genuinely
|
||
differ in the original source.
|
||
|
||
**`match_assert` stringizes source spacing.** With the bytes byte-for-byte
|
||
identical, the comparator still refused on one relocation: January's assert text
|
||
is `"camera != NULL"` and ours was `"camera!=NULL"`. `#expr` preserves whatever
|
||
the source wrote, so the operator spacing inside an assert is load-bearing even
|
||
in a file whose prevailing style is unspaced. `house_rules` §3 says spacing is
|
||
per-file; this shows it can be **per-expression** when an assert captures it.
|
||
|
||
## `ai_debug_speak_list` byte-exact
|
||
|
||
Object **47/60, 8048 bytes -> 48/60, 8512**.
|
||
|
||
A local, **null-terminated** table of fifteen 8-byte records --
|
||
`{char const *name; short vocalization_type; boolean all;}` -- naming the
|
||
vocalization ranges: `all` at 0, `idle` 0, `involuntary` 6, `hurting people` 21,
|
||
`being hurt` 29, `killing people` 49, `player kill comments` 80,
|
||
`friends dying` 96, `shouting` 108, `group communication` 123, `actions` 148,
|
||
`exclamations` 177, `post-combat actions` 188, `post-combat chatter` 197, then
|
||
`{NULL, NONE, FALSE}`. Fifteen times eight is `0x78`, the exact frame, which is
|
||
what confirms the terminator is a real record rather than padding.
|
||
|
||
`all` is `TRUE` only on the first entry, and it lands in `field_85B22` -- the
|
||
same flag `code_000386a0` reads to decide whether to keep advancing past
|
||
vocalizations named `"unused"`. Two functions agreeing on its meaning is better
|
||
evidence than either alone, though the field keeps its placeholder name because
|
||
`ai_debug_speak` also writes it and renaming would reach an already-exact
|
||
function.
|
||
|
||
The only residual at exact size was **block order**: January's `jne 0x180`
|
||
sends the *found* case forward and falls through to the not-found message, so
|
||
the source tests `if (!entry->name)` first. Written the natural way round --
|
||
`if (entry->name) { work } else { report }` -- everything matches except which
|
||
half sits where. This is the fallthrough rule reaching a whole basic block
|
||
rather than a ternary arm, and it is now the fourth instance in this object.
|
||
|
||
## `code_000383d0`: 512/512, five bytes short of exact
|
||
|
||
Written, **not** credited. 512 against 512 with **five differing bytes** in one
|
||
place, and the `__unknown859F4` block fully named on the way.
|
||
|
||
### The aiming-validity block names itself
|
||
|
||
`0x859F4` is a cached copy of `selected_actor_index`; when it changes, two
|
||
booleans are cleared. The block then runs to `0x85A14`, which is
|
||
`idle_look_valid` -- so the extent is fixed by the next named field:
|
||
|
||
```
|
||
0x859F4 long field_859F4 cached selected actor
|
||
0x859F8 boolean field_859F8
|
||
0x859F9 boolean field_859F9
|
||
0x859FC real_vector3d field_859FC
|
||
0x85A08 real_vector3d field_85A08
|
||
```
|
||
|
||
`4+1+1+2+12+12 = 32`, exactly the old `__unknown859F4[32]`. The two booleans
|
||
and two vectors are the out-parameters of
|
||
`actor_looking_test_validity(actor_index, &vector, &a, &b)`, which is otherwise
|
||
undeclared in the tree; the offset oracle also gave `actor.input.facing_vector`
|
||
at `0x174`.
|
||
|
||
### The subobject pointer has a scope, not just a name
|
||
|
||
Two rounds of the §6 rule again, and this one added a third dimension. Reading
|
||
`[esi+0x124]`/`[esi+0x128]` instead of January's `[edi+4]`/`[edi+8]` cost 6
|
||
bytes, so the head position wants a named pointer -- but naming it at the top of
|
||
the enclosing block made the function **32 bytes short**, because MSVC then also
|
||
used it for the two tail blocks. January pops `edi` *before* those, and they use
|
||
the long `[esi+0x120]` form.
|
||
|
||
Scoping the pointer to the `if (camera)` block gave the exact 512. So the rule
|
||
now reads: whether the subobject is named decides the register split, where the
|
||
name is born decides the `lea` placement, and **where the name dies decides how
|
||
far the addressing mode reaches**.
|
||
|
||
### The residual
|
||
|
||
Five bytes at `0x15D`: January emits `push 1` then `fstp [ebp-0x10]`, ours the
|
||
reverse -- the `TRUE` argument pushed before the point's first component is
|
||
stored rather than after. Only the *first* of the two structurally identical
|
||
tail blocks differs; the second matches. Both the named-local and the
|
||
nested-`point_from_line3d`-as-argument spellings produce the same five bytes, so
|
||
the call shape is not the lever. Left as the simpler named-local form.
|
||
|
||
## `code_00039e80`: decode started, not written
|
||
|
||
1120 bytes, frame `0x230`, still a stub. Recording what is established so the
|
||
next attempt does not re-read it.
|
||
|
||
**It iterates every unit in the world**, not the selected actor:
|
||
|
||
```c
|
||
object_iterator_new(&iterator, _object_mask_unit, 0);
|
||
while ((unit = object_iterator_next(&iterator))!=NULL) { ... }
|
||
```
|
||
|
||
`struct object_iterator` is already declared (16 bytes; the object index at `+8`
|
||
is the `[ebp-0x28]` the body reads). Per unit it takes
|
||
`unit_get_head_position(object_index, &head)`, offsets by `global_up3d*0.1f`,
|
||
and calls `ai_debug_drawstack_setup`.
|
||
|
||
**Three independent gates follow**, matching the dispatcher's condition
|
||
`render_speech || print_speech || render_dialogue_variants`. The first, at
|
||
`0xD3` (`render_dialogue_variants`), is decoded:
|
||
|
||
* `tag_get('unit', unit->definition_index)` gives the unit definition, whose
|
||
`+0x2B4` is a `tag_block` of `0x18`-byte elements.
|
||
* It scans that block for the element whose `+0x14` equals the unit's `+0x334`,
|
||
and takes that element's leading `short`.
|
||
* The label is `tag_name_strip_path(tag_get_name(unit-><0x334>))`, defaulting to
|
||
the literal `"<none>"`.
|
||
|
||
Two things to know before starting:
|
||
|
||
* **`ai_debug_drawstack` is inlined here.** The next/last position swap and the
|
||
`up * drawstack_height` add appear as straight-line code at `0x109`-`0x147`,
|
||
where every other function in this object emits a `call`. Writing it as a call
|
||
will not match, and the file-local-static-versus-inline distinction in §6 is
|
||
the relevant rule.
|
||
* Two fields still need names: unit definition `+0x2B4` (the dialogue-variant
|
||
block) and unit `+0x334`. `unit.speech` is at `0x338`, so `+0x334` is the
|
||
field immediately before it -- run the offset oracle on `_unit_datum` rather
|
||
than guessing.
|
||
|
||
### `code_000383d0` closed: the two tail blocks are written differently
|
||
|
||
Object **48/60, 8512 bytes -> 49/60, 9024**.
|
||
|
||
The five-byte residual was not scheduling. The two tail blocks are structurally
|
||
identical -- offset the head position by `up*0.05f`, draw a vector -- but the
|
||
original writes them **differently**:
|
||
|
||
```c
|
||
/* first block, the aiming vector */
|
||
point.x = global_up3d->i*0.05f + actor->input.position.head_position.x;
|
||
point.y = global_up3d->j*0.05f + actor->input.position.head_position.y;
|
||
point.z = global_up3d->k*0.05f + actor->input.position.head_position.z;
|
||
|
||
/* second block, the looking vector */
|
||
point_from_line3d(&actor->input.position.head_position, global_up3d, 0.05f, &point);
|
||
```
|
||
|
||
The explicit form finishes the argument list before storing `point.x`
|
||
(`push edx / push 1 / fstp`); the helper form stores first. Both spellings were
|
||
tried on *both* blocks and neither uniform choice matched -- only the mixed one
|
||
does.
|
||
|
||
This is the second time in two functions that structurally identical code turned
|
||
out to be spelled two different ways in the same function: the pair of
|
||
`swarm_actor_index` ternaries in `code_00038280` had opposite arm order, and
|
||
these two blocks use different helpers. **Normalising repeated code to one form
|
||
is an active mistake**, and a residual that survives applying the same fix to
|
||
every site is the signal for it.
|
||
|
||
### `code_00037fa0`: the residual is one constant, and ten forms do not move it
|
||
|
||
Still 304/304 with 21 differing bytes, but the difference is now fully
|
||
characterised, which it was not before. It is **a single immediate**:
|
||
|
||
```
|
||
January mov esi, 0x402f8 ; &pair[0].reference_count (element + 4)
|
||
ours mov esi, 0x402f6 ; &pair[0].end_index (element + 2)
|
||
```
|
||
|
||
Every instruction after that is identical, displaced by two and shifted one byte
|
||
because `[esi]` encodes shorter than `[esi+2]`. The three field reads are
|
||
`reference_count`, `end_index`, `start_index` in both builds, in that order.
|
||
So MSVC anchors the strength-reduced induction pointer on the **highest**
|
||
accessed field and we anchor on the middle one, and nothing about the access
|
||
order explains it.
|
||
|
||
Forms measured and rejected, all 304/304 unless noted:
|
||
|
||
| form | result |
|
||
|---|--:|
|
||
| `long` local assigned from `pair->reference_count` | 21 |
|
||
| direct `MIN(pair->reference_count, 12)` | 16-bit compare, +16 bytes |
|
||
| `long` local, no `pair` pointer, array subscripts throughout | 21 |
|
||
| `long` local hoisted to function scope | 21 |
|
||
| mixed: `pair->` for the count, subscripts for the points | 21 (CSE'd back) |
|
||
| explicit pointer induction (`pair++` in the for-clause) | 21 |
|
||
| `start_point`/`end_point` named before the count | 41 |
|
||
| `reference_count = MIN(pair->reference_count, 12)` | 320 bytes, 73 |
|
||
| `12L` bound | no effect |
|
||
|
||
The two halves still pull against each other exactly as recorded earlier: any
|
||
form that puts the value through a 32-bit store gets the compare width right and
|
||
the anchor wrong; the direct expression gets the anchor right and the width
|
||
wrong. Nothing tried gets both.
|
||
|
||
**What has not been tried** and is the obvious next move: the sibling loop.
|
||
Loop 1 is byte-exact and reads `lineofsight_point_reference_count[index]`, a
|
||
*separate array*, so it never exercises the anchor question. If the anchor is
|
||
chosen by something in the enclosing function rather than the loop -- register
|
||
pressure from the 13-entry colour table, say -- then loop 2 cannot be fixed in
|
||
isolation and the lever is elsewhere in the function.
|
||
|
||
## `ai_debug_render_actor`: three negatives worth having
|
||
|
||
No score change. The function stays **24,960 against 24,976 with the frame at
|
||
`0x81C` against `0x810`** -- 16 bytes short and 12 bytes of frame over.
|
||
|
||
### The relocation diff is entirely a csplit artifact -- do not chase it
|
||
|
||
A naive relocation-name comparison reports 109 differences: January has
|
||
`_ai_debug_render_actor_jmptable` x12 and `_ai_debug_render_actor` x97, ours has
|
||
about a hundred `$L…` internal labels. **This is the switch jump table and its
|
||
self-references, and it does not block the comparator** -- the csplit research
|
||
established that by blinding internal relocations across all 18 jump-table
|
||
residuals, and 93 exact functions in the tree contain one. Any future session
|
||
should filter internals out before reading a relocation diff on this function,
|
||
or the real signal is buried.
|
||
|
||
### All three mechanical detectors are clean on the whole object
|
||
|
||
* `inverted_branch_scan` -- 0 hits (it found and closed two real bugs here
|
||
earlier, including the inverted `actor->meta.swarm` guard).
|
||
* `loop_index_width_scan` -- 0 downcounter differences across 58 shared functions.
|
||
* `access_width_scan` -- 0 aligned width mismatches.
|
||
|
||
So the residual is **not** a branch-polarity, index-width or access-width defect.
|
||
That is a real narrowing: those three classes account for most of what has closed
|
||
in this object, and none of them applies here.
|
||
|
||
### Rejected: reusing `p2` as the PVS branch's scratch
|
||
|
||
The frame is over by exactly one 12-byte point, and the PVS branch declares five
|
||
points where the other two declare four -- `p1` exists only to build `p3` and
|
||
`p2`. Reusing `p2` as that scratch is the idiom §5 of this log records working at
|
||
another site in the same function, so it was the obvious candidate.
|
||
|
||
It is wrong. Frame went **up** to `0x820` and size to **25,088** (+112), because
|
||
making `p2` both source and destination of the subtraction forces MSVC to
|
||
materialise a temporary of its own.
|
||
|
||
Together with the three forms the earlier session measured -- `p1` as three
|
||
`real` scalars (0x820, +128), `p1` eliminated relying on CSE (0x820, +128, and
|
||
three surplus constants), and a subobject pointer for the direction vector
|
||
(-80) -- **four** ways of removing that fifth point are now measured and all are
|
||
worse. The frame surplus is not reachable by deleting a local from this branch.
|
||
|
||
## `code_0003a2e0`: decode started, `ai_globals` extended
|
||
|
||
1152 bytes, frame `0x180`, still a stub. About 400 bytes read; recording the
|
||
structure so the next attempt starts from evidence.
|
||
|
||
### The enterable-vehicle array, extent proved
|
||
|
||
`ai_globals` carries a second array, and it proves its own extent exactly the way
|
||
the spatial-effect ring did:
|
||
|
||
```
|
||
0x3B4 boolean field_3B4 (written by ai.c, read by actions.c)
|
||
0x3B6 short enterable_vehicle_count
|
||
0x3B8 struct ai_enterable_vehicle[32] stride 0x28
|
||
0x8B8 short field_8B8 (ai.c increments it)
|
||
```
|
||
|
||
`0x3B8 + 32*0x28 = 0x8B8`, and `0x8B8` is the next `ai_globals` offset used
|
||
anywhere -- `ai.c` does `inc word ptr [eax+0x8b8]`. Two files that do not know
|
||
about each other agreeing on the boundary is the same signal that fixed the
|
||
effect ring at 32. Asserts added for both offsets and the `0x28` record size.
|
||
|
||
Three record fields are read by this function: `object_index` at `+0`,
|
||
`distance` at `+4` (printed as `"enterable: dist %.1f"`), and a `short` team
|
||
bitmask at `+8`. The remaining 30 bytes stay unknown.
|
||
|
||
### What the function does so far
|
||
|
||
Iterates `enterable_vehicle[0 .. enterable_vehicle_count)`. Per entry it verifies
|
||
the object as a unit, takes `object_get_origin`, offsets the drawstack by
|
||
`global_up3d*0.5f`, and prints the distance. When the team bitmask is non-zero it
|
||
builds a `"teams: "` string by walking bits 0..9 against a **ten-entry local
|
||
table** -- `default, player, human, covenant, flood, sentinel, unused6..unused9`
|
||
-- appending `" "` and the name with `csstrcat` for each set bit. That is the
|
||
same team-name table `ai_debug_render_actor` carries at `[ebp-0xb0]`, which is
|
||
worth knowing: it is a local in both, not a shared static.
|
||
|
||
Roughly 750 bytes past `0x180` are unread.
|
||
|
||
## `code_0003a2e0` written: 1136 against 1152, allocation only
|
||
|
||
No longer a stub. Every structure, string, constant and call is in place; the
|
||
residual is **16 bytes and 4 bytes of frame**, entirely register allocation.
|
||
|
||
### Both name tables prove their own size
|
||
|
||
The function prints three things per enterable vehicle, and two of them are
|
||
bitmask-to-name expansions over **local** tables whose sizes are confirmed by
|
||
existing enumerations rather than counted off the disassembly:
|
||
|
||
* teams -- 10 entries, `default player human covenant flood sentinel unused6..9`,
|
||
exactly `NUMBER_OF_SOLO_CAMPAIGN_TEAMS` in `game.h`, names matching the enum
|
||
member for member;
|
||
* actor types -- 16 entries, `elite`..`mounted weapon`, exactly
|
||
`NUMBER_OF_ACTOR_TYPES` in `actor_types.h`, again matching member for member.
|
||
|
||
That completes the record: `object_index` `+0`, `distance` `+4`, `team_flags`
|
||
`+8`, `actor_type_flags` `+0xA`, `actor_count` `+0xC`, `actor_index[6]` `+0x10`
|
||
-- `0x28`, and the array's 32 entries land on `0x8B8` as recorded above.
|
||
|
||
The third block calls `ai_index_to_string(ai_index, global_scenario_get(),
|
||
string, 256)`, declared here for the first time.
|
||
|
||
### The residual is the render_actor phenomenon again
|
||
|
||
January is under **more** register pressure than we are and spills accordingly:
|
||
|
||
* it keeps `ai_globals` and the vehicle pointer in the *same* register, reloading
|
||
`[_ai_globals]` at the bottom of every outer iteration, and spills the vehicle
|
||
pointer to `[ebp-0xC]` across the first call;
|
||
* it walks each name table through a **memory** pointer at `[ebp-8]` with the
|
||
countdown at `[ebp-4]`, where we hold the table pointer in a register.
|
||
|
||
That is four bytes of frame and sixteen bytes of code, all in our favour, and it
|
||
is the same signature as `ai_debug_render_actor` -- *our allocator is under less
|
||
pressure than January's.* Writing the table walk as an explicit
|
||
`char const **name = table; … name++` pointer induction does **not** reproduce
|
||
it: MSVC folds it straight back to indexed addressing, same 1136 bytes.
|
||
|
||
Two functions now share this exact signature. It is worth treating as a single
|
||
open question about what raises VC7's pressure in this translation unit, rather
|
||
than as two independent scheduling puzzles.
|
||
|
||
## `code_00041220`: head decoded, not written
|
||
|
||
2336 bytes, frame `0x8EC`, still a stub and the largest unwritten function in the
|
||
object. Only the first `0x90` is read. Recording it so the next attempt starts
|
||
from evidence rather than a cold dump.
|
||
|
||
It takes the encounter index as its one stack argument and opens with:
|
||
|
||
```c
|
||
encounter_get(encounter_index); /* result discarded -- the assert */
|
||
definition = TAG_BLOCK_GET_ELEMENT(&global_scenario_get()->ai_encounters,
|
||
encounter_index&0xffff, struct encounter_definition); /* kept in ebx */
|
||
|
||
encounter_actor_iterator_new(&iterator, encounter_index);
|
||
while (encounter_actor_iterator_next(&iterator))
|
||
{
|
||
boolean selected = (ai_debug.selected_actor_index==iterator.actor_index);
|
||
...
|
||
if (!selected && ai_debug.selected_actor_index!=NONE &&
|
||
!ai_debug.render_all_actors)
|
||
continue;
|
||
...
|
||
}
|
||
```
|
||
|
||
The `datum_get` at `0x16` has its result overwritten immediately, so it is there
|
||
for its own assert -- the same shape as the discarded `unit_get` in
|
||
`code_00038280`. `[ebp-0x38]` is the `encounter_actor_iterator` (its
|
||
`actor_index` read at `[ebp-0x34]`, `+4`, confirming the struct again), and
|
||
`[ebp-0x24]` is the `selected` boolean produced by `sete`.
|
||
|
||
What is already known from the relocation survey, and does not need re-deriving:
|
||
this function is the **writer** of the 64-byte `ai_debug_actor_record` array at
|
||
`0x7D9F4`, touching `+0`, `+1`, `+0x24`, `+0x28`, `+0x2C` and `+0x34` of one
|
||
record, plus `0x7D380`, `0x7D3C7` and the `0x7D980` block. The record stride and
|
||
the 512-entry count are already proved by `code_000383d0`'s lowest reference
|
||
landing on `0x859F4`.
|
||
|
||
### `code_0003a2e0`: declaration scope is not the lever either
|
||
|
||
Hoisting both name tables and the `string[256]` buffer from their guarded blocks
|
||
to function scope produces **byte-identical output** -- still 1136 against 1152,
|
||
frame still `0x17C` against `0x180`. MSVC lays the frame out the same way
|
||
regardless, so the arrays' declaration scope is neutral here and the missing
|
||
dword is not one of them.
|
||
|
||
The frame accounting is exact and worth writing down, because it says precisely
|
||
what is missing. January's `0x180`:
|
||
|
||
```
|
||
[ebp-0x180] char string[256]
|
||
[ebp-0x080] actor_type_names[16] 0x40
|
||
[ebp-0x040] team_names[10] 0x28
|
||
[ebp-0x018] real_point3d origin 0x0C
|
||
[ebp-0x00C] spilled vehicle pointer 4
|
||
[ebp-0x008] table walk pointer 4
|
||
[ebp-0x004] countdown 4
|
||
```
|
||
|
||
`256+64+40+12+4+4+4 = 384 = 0x180`. Ours is `0x17C` -- **one dword short, and it
|
||
is the spilled vehicle pointer**. January reuses a single register for
|
||
`ai_globals` and then for `&vehicle`, destroying the global and reloading
|
||
`[_ai_globals]` at the bottom of every outer iteration; we keep `ai_globals`
|
||
live in `ebx` for the whole function and never need the spill.
|
||
|
||
So the question is narrow and shared with `ai_debug_render_actor`: **what makes
|
||
VC7 decline to hoist a global pointer across the calls in this loop?** Five
|
||
source forms are now measured against it -- named `vehicle` pointer, direct
|
||
subscripting, pointer induction over the tables, tables at block scope, tables at
|
||
function scope -- and all five hoist. Until that is answered neither function can
|
||
close, and answering it once closes 26 KB.
|
||
|
||
## code_0003a2e0 — matched (1152/1152). The question was never hoisting.
|
||
|
||
For several sessions this and `ai_debug_render_actor` were parked on "what makes
|
||
VC7 decline to hoist `_ai_globals` across the calls in this loop?" That question
|
||
had a false premise. Both builds do the same thing with the global: load it at
|
||
the top, turn it into `&vehicle` in a callee-saved register, and reload it at the
|
||
bottom for the loop test. Nothing declines to hoist.
|
||
|
||
What differed was which locals won the three callee-saved registers. Tracing
|
||
every write to `esi` in January gives exactly seven:
|
||
|
||
0011 xor esi, esi 0290 mov esi, 0x10 045f inc esi
|
||
0129 mov esi, 0xa 037c xor esi, esi 046f pop esi
|
||
03d2 inc esi
|
||
|
||
`0xa` and `0x10` are `NUMBER_OF_SOLO_CAMPAIGN_TEAMS` and `NUMBER_OF_ACTOR_TYPES`.
|
||
They are not scratch — they are the **constant-folded exit values** of the two
|
||
fixed-trip inner loops, written straight into the outer loop's counter. A
|
||
compiler only materialises one loop's exit value into another loop's counter if
|
||
they are the same variable. All four loops in the function share one `short`.
|
||
|
||
The consequences cascade exactly as observed:
|
||
|
||
- one live range spans the whole body, so it holds `esi` outright;
|
||
- the team and actor-type loops get `ebx` and `edi` for their own induction;
|
||
- with all three callee-saved registers taken, each inner loop's pointer and
|
||
downcounter are pushed to memory (`[ebp-8]`, `[ebp-4]`);
|
||
- `&vehicle` is spilled to `[ebp-0xc]` and reloaded at 0x35c — the dword our
|
||
frame was missing (0x180 vs 0x17c).
|
||
|
||
Four separate indices, as we had them, give the allocator a short outer range it
|
||
is happy to leave in memory, and then `esi`/`edi` are free for the inner loops.
|
||
Same instructions either way; entirely different allocation.
|
||
|
||
**Rule.** Before treating a register assignment as a compiler convention, count
|
||
the loop variables. A constant equal to an inner loop's trip count appearing in
|
||
the outer loop's counter register is proof the source shares one variable.
|
||
|
||
## ai_debug_vocalize — matched (208/208). Declaration site, not register convention.
|
||
|
||
Parked as an "esi/edi swap". It was a placement: January stores NONE into
|
||
`[ebp-8]` at 0x73, after both guards, immediately before the `unit_test_speech`
|
||
call that takes its address. We stored it at 0x56, beside `vocalization_type`
|
||
and before the guards — because `sound_definition_index` was declared beside
|
||
them. Moving the declaration inside the `if (priority>0 && vocalization_type
|
||
!=NONE)` block shortens the live range past the guards and all three registers
|
||
fall into January's assignment on their own.
|
||
|
||
Same lever as code_0003a2e0 from the other end: there a live range too short,
|
||
here one too long. Register assignment is not dictatable and was never the thing
|
||
to reason about. The declaration site is.
|
||
|
||
## code_00037fa0 — still NonMatching (320 vs 304). Now down to one typed expression.
|
||
|
||
The second loop walks the 6-byte `ai_debug_lineofsight_pair` records with a
|
||
strength-reduced pointer. Two independent properties were wrong; six source
|
||
forms were measured.
|
||
|
||
**The anchor.** January anchors the induction pointer at record+4
|
||
(`mov esi, 0x402f8`), ours at record+2. Measured:
|
||
|
||
| form | anchor |
|
||
|-------------------------------------------------------------|--------|
|
||
| `reference_count = pair->reference_count;` then call | +2 |
|
||
| same, but direct `lineofsight_pair[i]` subscripts throughout | +2 |
|
||
| block-scope `long x = pair->reference_count;` then call | +2 |
|
||
| all three field reads inside the call | +4 ok |
|
||
| start/end hoisted to locals, count still a statement | +4 ok |
|
||
| `color` local initialised from the count, points in the call | +4 ok |
|
||
|
||
A `pair->field` read that is its own assignment is excluded from the cluster the
|
||
compiler strength-reduces; the pointer then anchors on the highest offset among
|
||
the reads that remain inside the call.
|
||
|
||
**The width.** January emits `movsx eax, word ptr [esi]` / `cmp eax, 0xc`; every
|
||
form of ours that anchors correctly emits `mov ax` / `cmp ax` and widens later,
|
||
costing 6 bytes and a `jmp`. The only forms that widen at the load are the ones
|
||
routing the field through a `long` — and those are exactly the forms that lose
|
||
the anchor. The two properties are mutually exclusive across everything tried,
|
||
so one premise is wrong, and it is most likely "32-bit width needs a long
|
||
intermediate".
|
||
|
||
Ruled out for the ceiling operand: `NUMBEROF(colors)-1` (cseries.h:140 yields
|
||
`size_t`, which would give `jbe`, not January's `jle`), and `PIN` (would emit a
|
||
floor test January does not have). A corpus sweep for exact functions emitting
|
||
`movsx r32, word ptr [..]` / `cmp r32, imm8` / `j..` / `mov r32, imm8` returns
|
||
**zero** hits across 3,332 exact functions, so there is no worked example to read.
|
||
|
||
Parked at the form with the correct anchor and the correct evaluation order —
|
||
`color` as an initialised local, the two point subscripts inside the call — which
|
||
is also the shape code_00038f30 proved for this object. The single open question
|
||
is what gives the clamp expression `long` type without a separate statement.
|
||
|
||
## ai_debug_update — 1168/1168, net 0 bytes, one construct twice
|
||
|
||
Both `fix_defending_guard_firing_positions` and `fix_actor_variants` open the
|
||
same way and diverge identically (+8 bytes each, at 0x2f6 and 0x3b6):
|
||
|
||
January lea ebx, [eax + 0x42c] ours mov ebx, eax
|
||
cmp dword ptr [ebx], edi mov eax, dword ptr [ebx + 0x42c]
|
||
add ebx, 0x42c
|
||
cmp eax, edi
|
||
|
||
January forms `&scenario->ai_encounters` with one `lea` off the
|
||
`global_scenario_get()` result and folds the count read into the compare. We
|
||
keep the scenario pointer, read the count through a displacement, and only then
|
||
advance to the tag block — so the count needs a register. Everything else in
|
||
the function follows: the remaining hunks are the store order of the two zeroed
|
||
locals, and the `jmp` + `lea ecx,[ecx]` pair with which January aligns the loop
|
||
head to 0x310, which we do not need because our preamble is 8 bytes longer.
|
||
|
||
Measured and rejected, all byte-neutral or worse:
|
||
|
||
- declaring `encounters` after the two counters (moves a zero store *before* the
|
||
call — further from January, not closer);
|
||
- declaring `encounters` without an initialiser and assigning it as the first
|
||
statement;
|
||
- hoisting the inner `group_index` declaration out of the squad loop.
|
||
|
||
The scheduler is reading the count before the pointer exists, which is what MSVC
|
||
does when the pointer variable's first use comes after the count read. In our
|
||
source the initialiser is textually first, so the ordering is being set by
|
||
something else in the block, not by this declaration. Parked here rather than
|
||
guessed at.
|
||
|
||
## `code_00041220`: two more blocks decoded (0x90–0x230)
|
||
|
||
Adding to the head already recorded above. Float constants throughout:
|
||
`__real@3f800000` = 1.f, `__real@3e800000` = 0.25f, `__real@3d4ccccd` = 0.05f.
|
||
|
||
### 0xa3–0x119 — the colour table is self-measuring
|
||
|
||
`global_ai_debug_firing_position_color_count` is computed once, lazily, by
|
||
walking `global_ai_debug_firing_position_colors` until a component exceeds 1.f.
|
||
The loop is rotated and software-pipelined — `ecx` starts at `colors+8` and each
|
||
iteration tests `[ecx-4]`, `[ecx]`, `[ecx+4]`, `[ecx+8]` then `add ecx, 0x10` —
|
||
which is the four components of one `real_argb_color` plus the *first* component
|
||
of the next, with `colors[0].a` peeled out ahead of the loop at 0xab. `edx`
|
||
increments once per 16-byte colour, so it counts colours, not components:
|
||
|
||
```c
|
||
if (global_ai_debug_firing_position_color_count==0)
|
||
{
|
||
long color_count;
|
||
|
||
for (color_count = 0;
|
||
global_ai_debug_firing_position_colors[color_count].alpha<=1.f &&
|
||
global_ai_debug_firing_position_colors[color_count].red<=1.f &&
|
||
global_ai_debug_firing_position_colors[color_count].green<=1.f &&
|
||
global_ai_debug_firing_position_colors[color_count].blue<=1.f;
|
||
color_count++)
|
||
;
|
||
|
||
global_ai_debug_firing_position_color_count = color_count;
|
||
}
|
||
```
|
||
|
||
There is no bound test in the loop — **the table is sentinel-terminated by a
|
||
colour with a component above 1.f**, and the array must be declared that way.
|
||
The `mov [count], edx` at 0xb9 (storing zero before the peeled test) and again
|
||
at 0x113 are the same assignment on two paths, not two statements.
|
||
|
||
### 0x119–0x230 — the firing-position walk
|
||
|
||
`encounter_build_firing_position_owner_actor_indices(encounter_index,
|
||
&[ebp-0x8ec])` fills the large stack buffer the `0x8EC` frame is mostly there
|
||
for. Then the loop walks `ebx+0x98`, the encounter definition's firing-position
|
||
tag block, via `tag_block_get_element_with_size` with element size 8.
|
||
|
||
Each element `edi` is `{real x@0, real y@4, real z@8, short group_index@0xc}`.
|
||
The body builds four corners of a square, 0.25 out in x and y and 0.05 up in z,
|
||
into `[ebp-0x6c]` upward — twelve floats, four `real_point3d`, in
|
||
counter-clockwise order:
|
||
|
||
```
|
||
[-0x6c] x-0.25 [-0x68] y-0.25 [-0x64] z+0.05
|
||
[-0x60] x+0.25 [-0x5c] y-0.25 [-0x58] z+0.05
|
||
[-0x54] x+0.25 [-0x50] y+0.25 [-0x4c] z+0.05
|
||
[-0x48] x-0.25 [-0x44] y+0.25 [-0x40] z+0.05
|
||
```
|
||
|
||
MSVC computes each of the five distinct expressions once and `fst`s it to its
|
||
two or four homes, so the source is the plain twelve assignments in corner
|
||
order — do not try to reproduce the interleaving.
|
||
|
||
The colour for the position is `&global_ai_debug_firing_position_colors[
|
||
firing_position->group_index % global_ai_debug_firing_position_color_count]`,
|
||
emitted as `movsx`/`cdq`/`idiv`/`shl edx,4`/`add edx, offset colors` at
|
||
0x1f3–0x206 and parked in `[ebp-0xbc]`. The `shl 4` re-confirms
|
||
`sizeof(real_argb_color)==16`.
|
||
|
||
Still not written. What remains undecoded is 0x230–0x920, which is where the
|
||
`ai_debug_actor_record` array at `0x7D9F4` is written.
|
||
|
||
## `ai_debug_render`: the source is already right, it is gated on two bodies
|
||
|
||
288 of 336, three hunks, net -48. Both missing blocks are already written in our
|
||
`ai_debug_render` exactly as January has them —
|
||
|
||
```c
|
||
if (ai_debug.selected_squad_index!=NONE)
|
||
code_00041220(ai_debug.selected_squad_index); /* J 0x9b-0xab */
|
||
...
|
||
if (ai_debug.render_speech || ai_debug.print_speech ||
|
||
ai_debug.render_dialogue_variants)
|
||
code_00039e80(); /* J 0x103-0x11e */
|
||
```
|
||
|
||
— and MSVC deletes both because `code_00041220` and `code_00039e80` are empty
|
||
statics (house_rules §6: an empty referenced static is inlined away under /O2).
|
||
The field offsets confirm out of January: `0x34` `selected_squad_index`, `0xc9`
|
||
`render_speech`, `0x93` `print_speech`, `0xd3` `render_dialogue_variants`.
|
||
|
||
**Nothing about this function needs changing.** It closes the moment both
|
||
callees have bodies, and not before. It should not be counted as a residual with
|
||
a source question.
|
||
|
||
## `code_00039e80`: fully skeletonised (1120 bytes)
|
||
|
||
An `object_iterator` walk, `[ebp-0x30]`, over units; `esi` is the unit datum and
|
||
`[ebp-0x28]` its index. The loop back-edge is at 0x434 to 0x30, so everything
|
||
below is one loop body.
|
||
|
||
Preamble, once per unit — note **0.1f**, not the 0.05f used elsewhere in the
|
||
object:
|
||
|
||
```c
|
||
unit_get_head_position(unit_index, &head);
|
||
point.x = global_up3d->i*0.1f + head.x; /* __real@3dcccccd */
|
||
point.y = global_up3d->j*0.1f + head.y;
|
||
point.z = global_up3d->k*0.1f + head.z;
|
||
ai_debug_drawstack_setup(&point);
|
||
```
|
||
|
||
Then three independent guarded blocks, in this order:
|
||
|
||
1. `if (ai_debug.render_dialogue_variants)` (0x79) — `tag_get('unit', unit->tag)`,
|
||
then a linear search over the tag's `tag_block` at `+0x2b4` (element size
|
||
**0x18**) for the element whose tag index at `+0x14` equals `unit->[0x334]`,
|
||
taking that element's `short` at `+0`. Falls back to the literal `"<none>"`
|
||
and `NONE`. Prints `"variant %d dialogue %d %s"` with `unit->[0x6e]`.
|
||
2. `if (ai_debug.render_speech)` (0x1a5) — guarded on
|
||
`unit->[0x338] > 0`; names come from `tag_get_name`/`tag_name_strip_path` on
|
||
`unit->[0x33c]` and `dialogue_get_vocalization_name(unit->[0x33a], 0)`, each
|
||
falling back to `"NONE"`.
|
||
3. `if (ai_debug.print_speech && !ai_debug.render_speech)` (0x3ae) —
|
||
`unit_describe_speech(unit_index, 0, 0x200, buffer)` into `[ebp-0x230]`,
|
||
coloured by a **switch on `unit->[0x338]`**: `add eax,-2` / `cmp eax,8` /
|
||
`ja default`, so the cases are 2..10, dispatched through a byte index table
|
||
at 0x450 into a 4-entry jump table at 0x444. Three arms only —
|
||
`global_real_argb_red`, `global_real_argb_blue`, and white as the default.
|
||
|
||
### The trap, now pinned
|
||
|
||
`ai_debug_drawstack` is **inlined** in blocks 1 and 2 (the
|
||
`last_position = next_position; next_position += up3d*height` sequence appears
|
||
open-coded at 0x109 and 0x202) but **called** in block 3 (`call
|
||
_ai_debug_drawstack` at 0x414). Same function, same translation unit, two
|
||
treatments in one function — so this is MSVC's own inlining heuristic firing on
|
||
call-site context, not something the source spells differently. Do not try to
|
||
force it; write the three call sites identically and check what falls out.
|
||
|
||
Still needed before it can be written: `object_iterator_next` and
|
||
`unit_describe_speech` prototypes, `dialogue_get_vocalization_name`, and names
|
||
for `unit_datum` `0x334`, `0x338`, `0x33a`, `0x33c`, `0x36a` and `0x6e`. The
|
||
0x230-0x3a0 stretch (block 2's tail) is the only part not yet read.
|
||
|
||
## `ai_debug_render_actor`: the frame, and an unresolved trade
|
||
|
||
### What the frame permutation is, and what it is not
|
||
|
||
Aligning the two instruction streams with displacements blanked and reading off
|
||
every `[ebp-X]` pair recovers the frame permutation directly
|
||
(`framemap.py`). Two things fall straight out:
|
||
|
||
* everything at `0x2b0` and deeper sat at exactly **+12** in our build, so the
|
||
excess was one `real_point3d` allocated shallower than the buffers;
|
||
* the shallow slots `0xc`–`0x1c` already matched with 86, 38, 50, 47 and 52 uses
|
||
each, so the disagreement was localised, not global.
|
||
|
||
The slot *assignment* is a different matter and is **not** settable from
|
||
declaration order. Our declarations run `actor`, `actor_debug_info`,
|
||
`actor_definition`, `actor_variant_definition`, `path`; January's slots for those
|
||
same five are `0x104`, —, `0x74`, `0x20`, `0x108`. That is not any permutation of
|
||
declaration order — it is spill order, downstream of codegen. Do not try to
|
||
reorder declarations to chase it.
|
||
|
||
### The 12 bytes: `p4`
|
||
|
||
In the `BIT_VECTOR_TEST_FLAG(pvs, cluster_index)` arm we declared a fifth point
|
||
`p4` holding a copy of `base_point`, taken *before*
|
||
`point_from_line3d(&base_point, ...)`. MSVC cannot fold that copy away — the call
|
||
takes `base_point`'s address, so the old value may differ — and must keep `p4` as
|
||
a real 12-byte object. Passing `&base_point` to the two `render_debug_line` calls
|
||
directly removes it.
|
||
|
||
**Result: frame `0x810`, exactly January's.**
|
||
|
||
### The trade, stated honestly
|
||
|
||
This is not a clean win and should not be recorded as one.
|
||
|
||
| | frame | relocations |
|
||
|---|---|---|
|
||
| with `p4` | `0x81c` (12 over) | 1905 == 1905 |
|
||
| without `p4` | `0x810` **match** | 1904, one short |
|
||
|
||
The lost relocation is a single `_global_up3d` **reload** at January `0x1285`,
|
||
inside the same three-arm block: January reloads the pointer at the head of the
|
||
arm, we keep it live. That is a register-pressure/CSE consequence, not a missing
|
||
reference — no `global_up3d` *use* disappeared from the source.
|
||
|
||
So one of two things is true, and the evidence does not yet separate them:
|
||
|
||
1. `p4` is genuinely ours and the reload difference is an independent residual
|
||
that removing 12 bytes of stack traffic exposed; or
|
||
2. January really has five points here, and we carry a *different* redundant
|
||
12-byte object elsewhere, in which case both invariants would come good at
|
||
once by restoring `p4` and finding that other object.
|
||
|
||
The frame value is a sum over every stack object and landing on `0x810` exactly
|
||
is hard to do by accident, which favours (1). But a relocation count is a count
|
||
of real symbol references and had been matching, which is the stronger kind of
|
||
invariant. **Kept the change, because the frame was the question asked and the
|
||
byte arithmetic corroborates it — we were 16 short of January while carrying
|
||
`p4`'s 16-byte copy and are a clean 32 short without it — but this is flagged,
|
||
not settled.** Anyone resuming should first look for a second redundant
|
||
`real_point3d` before trusting the frame match.
|
||
|
||
### Resolved: there is no second redundant `real_point3d`
|
||
|
||
The flag raised above asked whether January really has five points in that arm
|
||
and we carry a different redundant twelve bytes elsewhere. Searched for, and the
|
||
answer is no. Three checks, with `p4` restored so the frame carried the excess:
|
||
|
||
* **Nothing in our frame is unmapped.** Aligning both instruction streams and
|
||
reading off every `[ebp-X]` pair, every slot of ours corresponds to a January
|
||
slot. A genuinely extra object would have no partner; none exists.
|
||
* **The one apparent twelve-byte hole is not an object.** Set membership puts
|
||
January's slots at or below `0x15c` in our frame as-is and everything from
|
||
`0x160` up only at +12, which looks like an extra local occupying our
|
||
`0x160`-`0x16b`. It has **zero** references anywhere in the function — no load,
|
||
no store, not even a `lea`. MSVC does not allocate an unreferenced local, so
|
||
that gap is an artifact of the permutation, not something we declared.
|
||
* **Source has no other candidate.** No remaining struct-copy assignment between
|
||
point or vector locals in the function.
|
||
|
||
So the twelve bytes were `p4` and nothing else, and removing it was right. The
|
||
lost `_global_up3d` reload is an independent residual that the reduced stack
|
||
traffic exposed, not evidence against the change. **Reading (1) above is the
|
||
correct one; the caveat on the frame match can be dropped.**
|
||
|
||
### What the search did turn up: two adjacent locals in the wrong order
|
||
|
||
The alignment map shows a clean transposition, the strongest pair at n=3:
|
||
|
||
jan 0x190-0x198 -> ours 0x19c-0x1a4 +12
|
||
jan 0x19c-0x1a4 -> ours 0x190-0x198 -12
|
||
|
||
Two adjacent twelve-byte locals with their order reversed — a declaration-order
|
||
difference, which unlike spill order *is* settable from source. Ours are the two
|
||
points whose `z` components are stored at `0x108f` (`fstp [ebp-0x19c]`, built
|
||
with 0.2f off `[ebp-0x3c]`) and `0x10ca` (`fstp [ebp-0x190]`, off `[ebp-0x60]`),
|
||
immediately before a `render_debug_line`. Swapping those two declarations is the
|
||
next concrete step, and it is independent of the spill-order permutation that
|
||
dominates the shallow slots.
|
||
|
||
### `ai_debug_render_actor`: declaration order does not move the frame either
|
||
|
||
Following the transposition found above (`jan 0x190-0x198` against
|
||
`ours 0x19c-0x1a4`), the two locals were identified: `p2` and `p3` in the
|
||
cluster-visibility arms, written by `point_from_line3d` inlined —
|
||
`fld [up3d+k]` / `fmul 0.2f` / `fadd [p0+k]` / `fstp [p2+k]`. January puts `p2`
|
||
shallower than `p3`; we put `p3` shallower.
|
||
|
||
Swapping the two declarations at all three sites changes **nothing at all**:
|
||
identical size, identical relocation count, identical slot-use agreement. So
|
||
declaration order does not drive the layout for block-scope points any more than
|
||
it does for the spilled pointers.
|
||
|
||
**Both halves of the frame question are now closed off.** Neither the spilled
|
||
pointers (shown earlier: January's slots for our first five declarations are not
|
||
any permutation of their declaration sequence) nor the block-scope points respond
|
||
to declaration order. The slot assignment is not a source lever in this function;
|
||
it will fall out when the surrounding code matches, not before. Anyone resuming
|
||
should spend their time on the 152 code-divergence sites and let the frame follow.
|
||
|
||
A baseline metric to measure against, from aligning both streams and comparing
|
||
the `[ebp-X]` displacement on every instruction that survives alignment:
|
||
|
||
slot-uses matching 364 / 915 size 24944 / 24976 relocs 1904 / 1905
|
||
|
||
### `ai_debug_update`: the loop indices are `short`, confirmed
|
||
|
||
Testing whether January's `cmp dword ptr [ebx], edi` came from `long` indices:
|
||
widening `encounter_index`, `squad_index` and `location_index` to `long` moves
|
||
the first differing byte from `0x2f6` all the way back to `0xb`, wrecking the
|
||
prologue. `short` is right and the divergence at `0x2f6` is not about the index
|
||
type. Four source forms rejected for this function now.
|
||
|
||
### `code_00039e80`: every speech offset resolved against declared types
|
||
|
||
Not written. But the data-model blocker is gone: the offsets this function uses
|
||
are **already declared** in `units.h`, and the mapping is verified by the
|
||
compiler rather than inferred. Appending
|
||
`char probe_X[offsetof(struct unit_datum, unit.X)+1];` for all 85 `_unit_datum`
|
||
fields to this unit and reading the COMMON symbol sizes back (offset = value-1)
|
||
gives `dialogue_index` at **0x334** and `speech` at **0x338** exactly, which
|
||
pins the whole run:
|
||
|
||
| target offset | field | read as |
|
||
|---|---|---|
|
||
| `0x334` | `unit->dialogue_index` | long, tag index |
|
||
| `0x338` / `0x33a` / `0x33c` | `unit->speech.current.priority` / `.vocalization_type` / `.sound_definition_index` | short / short / long |
|
||
| `0x368` / `0x36a` / `0x36c` | `unit->speech.queued.` same three | " |
|
||
| `0x3aa` / `0x3ae` | `unit->speech.sound_timer` / `.post_delay_timer` | short |
|
||
|
||
`sizeof(struct unit_speech_item)` is `0x30`, which is what makes `current` and
|
||
`queued` land 0x30 apart, and `last_damage_category` probes to `0x3b4`, so
|
||
`unit_speech` is `0x7c` — both consistent with the two sub-blocks being the
|
||
current and queued speech items. **No new field names are needed.**
|
||
|
||
Read off the call sites, both still undeclared:
|
||
|
||
```c
|
||
char const *unit_get_speech_priority_name(word priority); /* xor edx,edx / mov dx */
|
||
char const *unit_describe_speech(long unit_index, boolean abbreviated,
|
||
long buffer_size, char *buffer); /* (unit_index, 0, 0x200, &[ebp-0x230]) */
|
||
```
|
||
|
||
`object_iterator`, `object_iterator_new` and `object_iterator_next` are already
|
||
declared in `objects.h`, and `dialogue_get_vocalization_name` in
|
||
`dialogue_definitions.h`.
|
||
|
||
**The one thing still unnamed is `unit_datum + 0x6e`**, the `%d` printed as
|
||
"variant" in block 1. The probe run settles where it is not: every `_unit_datum`
|
||
field begins after `0x6e`, so it lies inside the preceding `object` member -- an
|
||
`_object_datum` field, not a unit one. Probe `struct unit_datum, object.*` the
|
||
same way to name it. It is used at exactly one site, so it must be named from
|
||
the probe and not guessed.
|
||
|
||
## `code_00039e80` written: 1120/1120, 107/107 relocations, one instruction out
|
||
|
||
Size and every relocation match. The sole difference is at `0x349`: January
|
||
schedules `xor eax, eax` before the `fstp`, we emit it after. Net zero bytes.
|
||
|
||
Nothing in it was invented:
|
||
|
||
* probing all 59 `_object_datum` fields names `unit_datum+0x6e` as
|
||
`object.variant_number` — exactly what the `"variant %d"` format prints;
|
||
* every speech offset lands on a field `units.h` already declared, through
|
||
`speech.current`, `speech.queued`, `speech.sound_timer`, `speech.post_delay_timer`;
|
||
* the switch index table at `0x450` is reproduced byte for byte — red for
|
||
priorities 2, 7, 10, blue for 6, white otherwise. The priority *names* are not
|
||
recovered, so the cases are written as the integers the target switches on.
|
||
Naming them would have been a guess with a single use site.
|
||
|
||
### The polarity lesson, applied twice in one function
|
||
|
||
Block 1's `NONE` check is a genuine `if` over a default (`je`, fallthrough into
|
||
the call). Block 2's four `NONE` checks are **ternaries** — January's `jne`
|
||
falls through into the `"NONE"` arm, which is the recorded rule that MSVC puts a
|
||
ternary's true arm in the fallthrough. Writing block 2 the way block 1 reads took
|
||
the function from **34 hunks to 8**. The two sites are spelled differently in the
|
||
original and normalising them costs 16 bytes.
|
||
|
||
### The `0x349` tie
|
||
|
||
Five forms measured, none moves it: queued declarations swapped (worse — first
|
||
difference retreats to `0x2c6`), strings inline in the `csprintf`, the drawstack
|
||
hoisted to a local, and the two string locals shared across both sub-blocks
|
||
(the `code_0003a2e0` reuse lever). Argument types are confirmed rather than
|
||
assumed: the guard is `jle`, so `priority` is signed `short`, and the
|
||
`xor eax,eax` / `mov ax` pair is what a `word` parameter takes — a `long`
|
||
parameter would emit a single `movsx`.
|
||
|
||
`ai_debug_render` moved from 288 to **320/336, 39/41 relocations** as a result;
|
||
the two missing relocations are exactly the `code_00041220` call and the
|
||
`selected_squad_index` read, so it closes when that function gets a body.
|
||
|
||
## `code_00041220`: decoded to 0x520 of 0x920. Not written.
|
||
|
||
Roughly 60% read. Recording it so the next attempt starts from evidence. Float
|
||
constants: `__real@3ec00000` = 0.375f, `__real@3f000000` = 0.5f,
|
||
`__real@3dcccccd` = 0.1f, `__real@3d4ccccd` = 0.05f, `__real@3e800000` = 0.25f.
|
||
|
||
### 0x00-0xa3 — the actor pass, fully readable
|
||
|
||
```c
|
||
encounter_get(encounter_index); /* discarded; there for its assert */
|
||
definition = TAG_BLOCK_GET_ELEMENT(&global_scenario_get()->ai_encounters,
|
||
encounter_index&0xFFFF, struct encounter_definition); /* ebx, element 0xb0 */
|
||
history_start_time = NONE; /* [ebp-0x3c] */
|
||
|
||
encounter_actor_iterator_new(&iterator, encounter_index); /* [ebp-0x38] */
|
||
while (encounter_actor_iterator_next(&iterator))
|
||
{
|
||
boolean selected = /* [ebp-0x24] */
|
||
(ai_debug.selected_actor_index==iterator.actor_index); /* [ebp-0x34] */
|
||
|
||
if (selected || ai_debug.selected_actor_index==NONE ||
|
||
ai_debug.render_all_actors)
|
||
{
|
||
ai_debug_render_actor(iterator.actor_index, selected, &history_start_time);
|
||
}
|
||
}
|
||
```
|
||
|
||
`&history_start_time` is the third parameter our `ai_debug_render_actor` already
|
||
declares, which is independent confirmation of that signature.
|
||
|
||
### 0x2a5-0x38b — firing-position group selection
|
||
|
||
Two `neg`/`sbb`/`and` sequences off one boolean at `unit+0x374`, which is the
|
||
MSVC idiom for a boolean-selected constant:
|
||
|
||
ebx = flag ? 3 : 0 ecx = flag ? 4 : 1 (and, earlier, flag ? 5 : 2)
|
||
|
||
then `if (esi->[0x98])` swaps which of the two indexes the group is read from.
|
||
Each is used as `test dword ptr [eax + index*4 + 0x54], edx` with
|
||
`edx = 1<<(firing_position->group_index)` — a bit-vector over firing-position
|
||
groups, at `+0x54` of the 0x80-strided element fetched at 0x29a. Matching colours
|
||
accumulate into an array at `[ebp-0xbc]` with a count in `[ebp-4]`; green when
|
||
the first test passes, aqua from the flags at `+0x6c`, white when the count is
|
||
still zero. The count is then asserted below 8:
|
||
|
||
display_assert("num_firing_po...", "c:\halo\SOURCE\...", 0x3c4 /* line 964 */, 1);
|
||
system_exit(-1);
|
||
|
||
### 0x38e-0x442 — the marker polygon
|
||
|
||
Four vertices built at `[ebp-0xec]`, 0.375f out from the firing position, drawn
|
||
with `render_debug_polygon(TRUE, points, 4, actor_action_debug_color(...))`.
|
||
|
||
### 0x445-0x520+ — the `ai_debug.field_CD` overlay
|
||
|
||
Guarded on `ai_debug` byte `0xcd`. Colour is red when `firing_position->[0x14]`
|
||
is `NONE`, white otherwise. Two points at `[ebp-0x14]` and `[ebp-0x20]` are
|
||
copied from the position and then walked through a sequence of ±0.5f and ±0.1f
|
||
adjustments with a `render_debug_line` after each pair — a cross or box glyph.
|
||
This stretch runs past 0x520 and is where the reading stopped.
|
||
|
||
### Still unread: 0x520-0x920
|
||
|
||
About 1,000 bytes, which per the relocation survey is where the
|
||
`ai_debug_actor_record` array at `0x7D9F4` is written. **Not written, and no
|
||
partial body committed** — `ai_debug_render` closes as soon as this function has
|
||
any body at all, so writing a speculative one would credit `ai_debug_render`
|
||
against a reconstruction that is not the original. It should be written once,
|
||
completely, from a full decode.
|
||
|
||
## `code_00041220`: 0x520-0x920 decoded. Whole function now read.
|
||
|
||
### The loop frame, settled
|
||
|
||
The firing-position loop runs `0x14a`-`0x8fa`. Its bookkeeping:
|
||
|
||
| slot | meaning |
|
||
|---|---|
|
||
| `[ebp-0x28]` | `short` firing-position index |
|
||
| `[ebp-8]` | the same index sign-extended to `long`, used for all array indexing |
|
||
| `[ebp-0x24]` | `struct tag_block *` = `&definition->firing_positions` (`ebx+0x98`) |
|
||
| `[ebp-0x8ec]` | the owner-actor-index array filled by `encounter_build_firing_position_owner_actor_indices` |
|
||
| `[ebp-0xbc]` | up to 8 accumulated colours, count in `[ebp-4]` |
|
||
|
||
The tail is `inc`, `movsx eax, ax`, `cmp` against `firing_positions->count`
|
||
reloaded through `[ebp-0x24]`, which is why both a `short` and a `long` copy of
|
||
the index are live at once.
|
||
|
||
### 0x540-0x5a9 — draw once per accumulated colour
|
||
|
||
Loops `esi` from 0 to the colour count. Per colour, either two
|
||
`render_debug_line` calls (points `[ebp-0x6c]`/`[ebp-0x54]` and
|
||
`[ebp-0x60]`/`[ebp-0x48]`) or a single
|
||
`render_debug_polygon_edges(&[ebp-0x6c], 4, colour)`. Both take the colour from
|
||
`[ebp+esi*4-0xbc]`, so this is the fan-out of the group colours gathered earlier.
|
||
|
||
### 0x5ab-0x5de — drawstack anchor
|
||
|
||
`point = firing_position + global_up3d*0.2f`, then `ai_debug_drawstack_setup`.
|
||
|
||
### The actor-record array, fully mapped
|
||
|
||
`shl esi, 6` — **stride 64** — with base `ai_debug + 0x7d9f4`. Fields touched:
|
||
|
||
| offset in record | absolute | read as |
|
||
|---|---|---|
|
||
| `+0x00` | `0x7d9f4` | boolean |
|
||
| `+0x01` | `0x7d9f5` | boolean |
|
||
| `+0x24`/`+0x28`/`+0x2c` | `0x7da18/1c/20` | a `real_point3d` |
|
||
| `+0x34` | `0x7da28` | boolean |
|
||
| `+0x38` | `0x7da2c` | `real`, compared against 0.0f |
|
||
| `+0x3c` | `0x7da30` | `real`, printed `"%3.2f"` (pushed as a qword — vararg double promotion) |
|
||
|
||
Companion globals: `ai_debug + 0x7d980` boolean, `0x7d3c7` boolean, and a
|
||
`real_point3d` at `0x7d988/8c/90`.
|
||
|
||
### 0x5e3-0x800 — the `ai_debug` byte-`0xc4` branch
|
||
|
||
Entered when `ai_debug[0xc4]` and `record->[0]` are both set.
|
||
|
||
* `actor_get_pursuit_location(...)` returns a pointer; the test is
|
||
`!= NULL && location->[0]==1 && location->[2]==firing_position_index`,
|
||
producing a flag in `bl`.
|
||
* `encounter_pursuit_position_already_examined(encounter_index,
|
||
ai_debug.selected_actor_index, history_start_time, index, &[ebp-0x2c], 0)` —
|
||
note `sete cl` / `dec ecx` / `and ecx, eax`, which is the idiom for
|
||
`history_start_time==NONE ? 0 : history_start_time`.
|
||
* Colour: yellow when `bl`, else blue when the call returned true, else white.
|
||
The drawstack is **inlined** here, then `csprintf(temporary, "%d", [ebp-0x2c])`.
|
||
* `0x771-0x800`: gated on `ai_debug[0x7d980] && ai_debug[0x7d3c7] &&
|
||
record->[1] && record->[0x34]`, draws two lines — yellow from the global point
|
||
at `0x7d988` plus `record->[0x24]`, then green back to the firing position.
|
||
|
||
### 0x805-0x8e3 — the else branch, byte `0xc3`
|
||
|
||
Gated on `ai_debug[0xc3] && record->[1] && !record->[0] &&
|
||
ai_debug.selected_actor_index!=NONE`. Colour ladder: red when `record->[0x34]`
|
||
is clear; otherwise if `record->[0x38] > 0.f` white, and within that yellow when
|
||
`*(long *)[ebp-4] == ai_debug.selected_actor_index` else blue; white when the
|
||
compare fails. Two `csprintf`/`ai_debug_drawstack`/`render_debug_string_at_point`
|
||
groups, the second printing `record->[0x3c]` as `"%3.2f"`. Here the drawstack is
|
||
a **real call**, not inlined — the same split this object showed in
|
||
`code_00039e80`.
|
||
|
||
### Symbols this function needs that the tree does not declare
|
||
|
||
`encounter_build_firing_position_owner_actor_indices`,
|
||
`encounter_pursuit_position_already_examined`, `actor_get_pursuit_location`,
|
||
`actor_action_debug_color`, `render_debug_polygon`, `render_debug_polygon_edges`.
|
||
Everything else it references is already declared.
|
||
|
||
The decode is complete; what remains before writing is naming the six prototypes
|
||
above and the `ai_debug` fields at `0xc3`, `0xc4`, `0xcd`, `0x7d380`, `0x7d3c7`,
|
||
`0x7d980`, `0x7d988` and the 64-byte record's members.
|
||
|
||
### `code_00041220`'s prototypes: four, not six
|
||
|
||
Correcting the list above. `actor_get_pursuit_location` and
|
||
`actor_action_debug_color` were **already declared** in `actions.h` (lines 444
|
||
and 446), along with `struct pursuit_location` at line 164 — and `ai_debug.c`
|
||
already calls the first at two other sites. Adding them to `actors.h` produced a
|
||
C2371 against the existing declarations, which is how the duplication surfaced.
|
||
Reverted; the existing ones are correct and the return type is
|
||
`struct pursuit_location *`, not a new type.
|
||
|
||
Four were genuinely missing, now declared, with the argument counts read off the
|
||
merged stack cleanups rather than assumed:
|
||
|
||
```c
|
||
void encounter_build_firing_position_owner_actor_indices(long encounter_index,
|
||
long *owner_actor_indices); /* push array, push index, add esp,8 */
|
||
boolean encounter_pursuit_position_already_examined(long encounter_index, long actor_index,
|
||
short firing_position_index, long start_time, short *examined_count, boolean flag);
|
||
void render_debug_polygon(real_point3d const *points, long point_count,
|
||
real_argb_color const *color);
|
||
void render_debug_polygon_edges(real_point3d const *points, long point_count,
|
||
real_argb_color const *color);
|
||
```
|
||
|
||
Two things worth keeping:
|
||
|
||
* **Neither polygon call takes a leading `immediate` flag**, unlike every
|
||
`render_debug_line`/`render_debug_string_at_point` in this object. The count
|
||
comes from `add esp, 0xc` on the `_edges` call, which has no other candidate.
|
||
* The `0x442` cleanup is `add esp, 0x18` covering **three merged calls** —
|
||
`datum_get` (2 args), `actor_action_debug_color` (1), `render_debug_polygon`
|
||
(3) — 8+4+12 = 24. Reading that as one call's argument list is how a wrong
|
||
arity gets baked in; the `actor_get(...)` result at `0x3b3` is discarded, the
|
||
same assert-only shape as `unit_get` in `code_00038280`.
|
||
|
||
The firing-position element is **0x18** bytes (`push 0x18` at `0x153`), with
|
||
`x`/`y`/`z` at 0, 4, 8, a `short` group index at `0xc`, and a `long` at `0x14`
|
||
tested against `NONE`.
|
||
|
||
### `code_00041220`: the types are almost all already in the tree
|
||
|
||
Checked before writing, and most of what I expected to have to name is declared:
|
||
|
||
* `encounter_definition.firing_positions` is at **0x98** and `.squads` at
|
||
**0x80** — the `ebx+0x98` and `eax+0x80` in the target — given a 12-byte
|
||
`tag_block` and the `unused[18]` run. Both already declared.
|
||
* `squad_definition.firing_position_groups` is at **0x54**, matching
|
||
`test dword ptr [eax+ebx*4+0x54], edx`. The fixed `[eax+0x6c]` test is
|
||
group index 6 of that same array.
|
||
* `struct ai_debug_actor_record actor_record[]` already sits at exactly
|
||
**0x7D9F4** (`field_7D384[1648]` ends there), with the 64-byte stride the
|
||
`shl esi, 6` requires. `field_01` is already named.
|
||
* Probing `struct ai_debug_state` names the three gates outright:
|
||
**0xc3 `render_evaluations`**, **0xc4 `render_pursuit`**,
|
||
**0xcd `render_firing_positions`**.
|
||
|
||
What is left before the body can be written, all mechanical:
|
||
|
||
1. split `ai_debug_actor_record.__unknown00`/`__unknown02` to name `+0x00`,
|
||
`+0x24`-`+0x2c` (a `real_point3d`), `+0x34`, `+0x38` and `+0x3c`;
|
||
2. split `field_7D384[1648]` to name `0x7D980` (boolean) and the
|
||
`real_point3d` at `0x7D988`;
|
||
3. locate `0x7D3C7`, which falls below `field_7D384` and so is inside the
|
||
`path_storage`/`__unknown608A9` run.
|
||
|
||
None of these needs a guess — each is a probe or an offsetof assert away, and
|
||
the `field_XXXX` convention this header already uses covers the ones whose
|
||
meaning is not recovered.
|
||
|
||
### `code_00041220` 0x217-0x2a5: the group colour table
|
||
|
||
The one stretch I had skipped, and it holds the block's shape. Seven colour
|
||
pointers are written to consecutive stack slots `[ebp-0x9c]` upward, which is the
|
||
array the later `mov ecx, [ebp+esi*4-0x9c]` indexes:
|
||
|
||
| index | slot | colour |
|
||
|---|---|---|
|
||
| 0 | `-0x9c` | `global_real_argb_red` |
|
||
| 1 | `-0x98` | `global_real_argb_orange` |
|
||
| 2 | `-0x94` | `global_real_argb_green` |
|
||
| 3 | `-0x90` | `global_real_argb_blue` |
|
||
| 4 | `-0x8c` | `global_real_argb_lightblue` |
|
||
| 5 | `-0x88` | `global_real_argb_green` |
|
||
| 6 | `-0x84` | `global_real_argb_aqua` |
|
||
|
||
Green appears twice, at 2 and 5 — written first, out of order, at `0x21d`-`0x228`
|
||
because MSVC shares the one relocation between both slots. Anyone reconstructing
|
||
the initialiser from emission order would get the entries wrong.
|
||
|
||
The rest of the block resolves the selected actor's own squad:
|
||
|
||
```c
|
||
struct actor_datum *actor = actor_get(ai_debug.selected_actor_index);
|
||
struct encounter_definition *actor_encounter = TAG_BLOCK_GET_ELEMENT(
|
||
&global_scenario_get()->ai_encounters,
|
||
DATUM_INDEX_TO_ABSOLUTE_INDEX(actor->meta.encounter_index), ...); /* 0xb0 */
|
||
struct squad_definition *squad = TAG_BLOCK_GET_ELEMENT(
|
||
&actor_encounter->squads, actor->[0x3a], struct squad_definition); /* 0xe8 */
|
||
```
|
||
|
||
`squad_definition` elements are **0xe8** bytes, and the squad index is the
|
||
`short` at `actor+0x3a`. The `neg`/`sbb`/`and` pairs then pick which of the
|
||
seven colours applies from the squad's `firing_position_groups` bit vector.
|
||
|
||
The decode is now genuinely complete — no unread stretches remain.
|
||
|
||
### `code_00041220`: first write attempt — compiles down to two unknown actor fields
|
||
|
||
Written in full and put through the compiler. Everything resolved except two
|
||
booleans read off the **actor datum**, which I had guessed as `meta.defending`
|
||
and `meta.berserk` and which do not exist:
|
||
|
||
* `actor + 0x374` — selects between firing-position groups `5` and `2` for the
|
||
first test, and between the `{3,4}` and `{0,1}` pairs for the second;
|
||
* `actor + 0x98` — swaps the attacking/defending pair when set.
|
||
|
||
Both are single-site reads in this object, so they need probing against
|
||
`struct actor_datum` the same way `unit_datum+0x6e` was resolved, not naming from
|
||
the surrounding logic. That is the whole remaining blocker: the body is written
|
||
and everything else in it type-checks.
|
||
|
||
Reverted rather than committed, so the tree stays green. Everything else from the
|
||
attempt is kept: `struct firing_position` is declared (0x18, `position` at 0,
|
||
`group_index` at 0xc, `field_14` at 0x14) and the seven-entry group colour table,
|
||
the squad lookup, the polygon draw and the colour-count assert at line 964 are
|
||
all written and compile.
|
||
|
||
## `ai_debug_update` — matched (1168/1168). The pointer local was the defect.
|
||
|
||
The construct at 0x2f6/0x3b6 — `lea` off the call result with the count folded
|
||
into `cmp [ebx], edi` — is not reachable while the source declares
|
||
`struct tag_block *encounters = &global_scenario_get()->ai_encounters;`. A
|
||
source-level pointer local is forward-substitution fodder: MSVC rewrites the
|
||
count read as `[scenario + 0x42c]`, hoists that load above the pointer
|
||
materialisation to hide latency, and pays `mov ebx,eax / mov eax,[ebx+0x42c] /
|
||
add ebx,0x42c / cmp eax,edi` — 8 bytes that then eat both of January's
|
||
loop-head alignment pads.
|
||
|
||
The worked example was in the exact corpus: scanning all 3,332 exact functions
|
||
for `lea r,[eax+imm]; cmp [r], r2` returns exactly one hit,
|
||
`find_netgame_flags` (game_engine.c), and its proven source keeps a
|
||
**`struct scenario *scenario` local and spells the tag block
|
||
`scenario->netgame_flags` at every use** — loop condition and
|
||
`TAG_BLOCK_GET_ELEMENT` alike. The `lea` is the compiler's own CSE of the
|
||
repeated address expression, and that CSE temp — unlike a named pointer —
|
||
is the canonical base for the count read, so the load folds into the compare.
|
||
|
||
Both fix blocks rewritten that way; both sites fell at once, and the zero-store
|
||
order and the `jmp / lea ecx,[ecx]` / `nop` pads followed on their own.
|
||
|
||
**Rule (pairs with the subobject-pointer rule):** when January reads a tag
|
||
block's count through a bare `[reg]` freshly `lea`'d from a call result, the
|
||
original has no pointer local for the block — it names the *parent* and spells
|
||
the member path everywhere. Scan the exact corpus for the emission shape before
|
||
theorising: one worked example outweighs any number of rejected guesses.
|
||
|
||
## `code_00037fa0` — matched (304/304). The anchor followed statement order, not spelling.
|
||
|
||
The parked question was "what gives the clamp expression `long` type without a
|
||
separate statement". Wrong question — the separate statement was fine; what
|
||
mattered was **when the record's address is first demanded relative to the
|
||
`pair` binding**. The exact form:
|
||
|
||
```c
|
||
struct ai_debug_lineofsight_pair *pair;
|
||
|
||
reference_count = ai_debug.lineofsight_pair[pair_index].reference_count;
|
||
pair = &ai_debug.lineofsight_pair[pair_index];
|
||
|
||
render_debug_line(TRUE, &point[pair->start_index], &point[pair->end_index],
|
||
*colors[MIN(reference_count, 12)]);
|
||
```
|
||
|
||
Same shared `long reference_count`, same subscript spelling of the hoist, and
|
||
the same `MIN` in the call as loop 1 — the author's one consistent voice — with
|
||
`pair` bound *after* the hoist, which is exactly what a programmer does when
|
||
they add the pointer later for the two long point expressions.
|
||
|
||
Eleven further forms measured this session (six were already logged). The two
|
||
properties resolve independently:
|
||
|
||
**Width.** The compare is 32-bit iff the compared lvalue is the `long` local
|
||
and the field read feeds *only* that store. Any direct consumer of the field —
|
||
condition, `MIN`, subscript, ternary arm, either arm order — lets the
|
||
narrowing pass canonicalise the whole cluster to 16-bit (`mov ax/cx`,
|
||
`cmp ax/cx`, per-arm `movsx`, +16 bytes). With mixed narrow/wide consumers VC7
|
||
loads narrow and widens register-to-register (`mov cx,[esi] / movsx eax,cx`);
|
||
only a wide-only consumer set fuses to `movsx eax,[mem]`. A guarded-overwrite
|
||
pair of `*colors[...]` expressions does not tail-merge, so every two-subscript
|
||
form pays a second table load. Corpus corroboration: across 3,332 exact
|
||
functions, every `movsx r32, word` + `cmp r32, imm` is a switch head (39),
|
||
an equality test (10), or a parameter/global (5 relational) — **no short
|
||
field is compared wide-relationally anywhere**, so the value must reach the
|
||
compare as a long.
|
||
|
||
**Anchor.** The induction pointer anchors at the canonical address of the
|
||
first-demanded read: `pair`-spelled reads contribute their field offset but a
|
||
store-feeding read is skipped (+2, five forms); a subscript-spelled hoist
|
||
*after* `pair` is bound re-roots the walk at the record base (+0); a
|
||
subscript-spelled hoist *before* `pair` is bound anchors at the hoisted
|
||
field itself (+4 — the only +4 that doesn't sacrifice the width). J and V1
|
||
differ only in the order of the copy and the binding: +0 against +4.
|
||
|
||
With both right, the `jle`-fallthrough `mov eax, 0xc`, the fold of the clamp
|
||
into the table index, and the 3/4-byte displacement split all follow. The
|
||
`if`-clamp spelling (`if (reference_count>12) reference_count = 12;` with a
|
||
plain `colors[reference_count]`) is byte-identical to the `MIN` form over the
|
||
long local — the macro was kept for loop-1 consistency.
|
||
|
||
## `code_00039e80` — matched (1120/1120, relocations identical). Two defects, neither where the diff pointed.
|
||
|
||
The `0x349` tie — January schedules the word-argument `xor eax,eax` above the
|
||
drawstack inline's first `fstp`, we below — was never movable from inside the
|
||
queued sub-block. Ten in-block forms are now measured and rejected: the seven
|
||
already logged, the csprintf result hoisted to a `string` local (restructures
|
||
the merged argument frames, `add esp,0x24`, -3 bytes, first diff retreats to
|
||
0x29), the colour routed through a local (0x314), and a block-3-style hoisted
|
||
`short priority` (0x29).
|
||
|
||
**What moved it was the body of `ai_debug_drawstack` itself.** Respelling the
|
||
callee from `point_from_line3d(...)` to the three component assignments leaves
|
||
the standalone function byte-identical — so the change is invisible to its own
|
||
section — but renumbers the IR that the inliner drops into every call site,
|
||
and VC7's scheduler breaks the `xor`/`fstp` tie by that order. The preamble's
|
||
spelling (longhand against `point_from_line3d`) changes nothing, measured both
|
||
ways. **Rule: when a scheduling tie sits at an inlined callee's boundary, the
|
||
callee's internal spelling is a live lever even though its own section already
|
||
matches. Sweep the callee, not just the call site.**
|
||
|
||
With the tie gone the byte stream is exact, and `section_infos_equal` exposed
|
||
the second defect, invisible to every raw-byte diff because relocation counts
|
||
matched at 107/107: the current-speech format string is
|
||
`"%s %s %s (%d %d)"` — January parenthesises the two timers — where the
|
||
reconstruction had `"%s %s %s %d %d"`. Wrong-string defects live in `.rdata`
|
||
COMDATs the gate does not count, so the only tell is the relocation *identity*
|
||
at the csprintf site. Fixed; the function passes strict comparison.
|
||
|
||
`ai_debug_render` (already exact) and `_ai_debug_drawstack` (standalone) are
|
||
both unchanged, and the object stands at **55/60 sections, 13,312/42,992
|
||
bytes**, with `ai_debug_update` and `code_00037fa0` also closed this session.
|
||
|
||
## `code_00041220`: seven recoveries, still NonMatching (2560 vs 2336)
|
||
|
||
Ranking the relocation multiset (not the byte diff) drove this whole pass, and
|
||
every recovery below was pinned by an identity difference before any source
|
||
was written.
|
||
|
||
1. **The assert carried bell characters.** The environment note about heredocs
|
||
collapsing backslashes struck again: line 964's `match_assert` file string
|
||
had single backslashes, so `\a` compiled to BEL — the mangled name showed
|
||
`?$AH` where January has `?2`. The expression string also needed January's
|
||
spaces (`a < b`, not `a<b`); stringize preserves source spacing.
|
||
2. **`record`/`owner`… the pointer question is per-object.** January spells
|
||
`ai_debug.actor_record[index].field` at every one of the fifteen uses
|
||
(J=34 `_ai_debug` references against our 19 — the delta equalled the use
|
||
count exactly), but it *does* hold `long *owner_actor_index` — the
|
||
evaluations compare goes `mov edx,[ebp-4] / cmp [edx],ecx` through the
|
||
spilled pointer. One derived pointer kept, one dissolved, in the same loop.
|
||
3. **The owner polygon is a second quad**, 0.375f out (`__real@3ec00000` J=4
|
||
O=0), built at `[ebp-0xec]` and passed to `render_debug_polygon`; the
|
||
0.25f quad only feeds the edge/line fan-out.
|
||
4. **Both quads are chained assignments, not twelve statements.** The
|
||
`fst/fst/fstp` runs with store order c3→c0 decode as
|
||
`corner[0].z = corner[1].z = corner[2].z = corner[3].z = pos->z+0.05f;`
|
||
and four two-home chains for x±/y±. The earlier decode's "plain twelve
|
||
assignments in corner order" was wrong — spelled that way the expressions
|
||
refuse to CSE (0.25f×8) because the corner stores sit between the reads.
|
||
5. **The colour-count global is its own loop variable.** January stores the
|
||
count on *two* paths (`0xb9` in the peeled test's flag shadow, `0x113` at
|
||
the loop exit) — the signature of
|
||
`for (global_..._count = 0; …; global_..._count++);` with the global
|
||
registerised, not a `color_count` local assigned afterwards.
|
||
6. **The fan-out loop raises the quad 0.05f per colour** — another chain,
|
||
`corner[0].z = … = corner[0].z+0.05f;`, missing from the reconstruction.
|
||
7. **The branch shapes.** The pursuit arm is two *sequential*
|
||
`if (selected!=NONE)` blocks (VC7 folds the second test on the failed
|
||
path — no call intervenes — and re-tests after the render's calls); the
|
||
`%d` render is ungated with a three-arm ladder
|
||
`pursued ? yellow : examined ? blue : white`; a fifth gate
|
||
`ai_debug.field_7D380` fronts the field_604 block; and the evaluations
|
||
arm needs `string_color` — NULL, assigned `global_real_argb_white` inside
|
||
the `field_38>0` arm, gating the inner render. The compiler cannot fold
|
||
`string_color != NULL` because the global's *value* is unknown, which is
|
||
exactly the dead-looking `mov eax,[white] / test eax,eax` January emits.
|
||
|
||
**What remains is only the drawstack split.** Relocation identity is now
|
||
clean except: January calls `_ai_debug_drawstack` at sites 1/3/4 and inlines
|
||
site 2; we inline 1/2/4 and call 3 (+224 bytes, the whole residual). The same
|
||
all-calls spelling fell out correctly in `code_00039e80` (3 inline + 1 call,
|
||
matched), so the inliner's per-site choice is context-driven and our remaining
|
||
context difference is presumably still upstream. The component-wise copy
|
||
respelling of the callee was measured and rejected — it wrecks the standalone
|
||
emission (48/60) — and January's function definition order matches ours.
|
||
|
||
**Process warning, earned the hard way:** a `replace('record->field_01', …)`
|
||
without function scoping rewrote `ai_debug_select_actor`'s clearing loop and
|
||
silently regressed four matched sections; the object dropped to 51/60 until
|
||
the line was found and restored. Scope mechanical renames to the function.
|
||
|
||
## The last five: a dependency chain, and two residuals localised
|
||
|
||
Object stands at **55/60, 13,312/42,992 bytes**. This pass did not close a
|
||
section; it established what each of the five remaining ones actually needs, and
|
||
fixed one frame. Recorded so the next attempt starts from evidence.
|
||
|
||
### Three of the five are one chain, not three problems
|
||
|
||
`ai_debug_render_path_storage` -> `code_0003a910` -> `code_00039990`, proven by
|
||
relocation: `_code_0003a910` is referenced only from `render_path_storage`
|
||
(0x315), and `_code_00039990` only from `code_0003a910` (0xb8, 0x263). Neither
|
||
static is declared in our source, which is exactly why `render_path_storage` is
|
||
640 against 816 — **its missing 176 bytes are the absent call**. Writing the two
|
||
statics should close all three sections, 2,368 bytes, and take the object to
|
||
58/60.
|
||
|
||
`code_00039990` (848B) is a waypoint renderer with **twelve parameters** — two in
|
||
a VC7 custom register convention (`eax` = the node, `edi` = a `real_point3d *`)
|
||
and ten on the stack. The call site at `code_0003a910:0xb7` gives the order
|
||
exactly (`add esp,0x28` = 10 args):
|
||
|
||
| arg | source at the call | use in the callee |
|
||
|---|---|---|
|
||
| 1 | `path->[0x64]` | passed to `ai_debug_render_surface` in a register |
|
||
| 2 | `a910`'s `[ebp+0xc]` | boolean gate at 0x114 |
|
||
| 3 | the path | NULL-tested, `[+0x24]` byte read |
|
||
| 4 | previous node or NULL | gate at 0xec |
|
||
| 5 | `global_real_argb_red` | **its stack slot is reused as a float scratch at 0x1f3** |
|
||
| 6-10 | five boolean-gated colours | render colours |
|
||
|
||
Its body: the midpoint of `*edi` and `node->entry_point`, raised by
|
||
`global_up3d*0.1f`; then the `ai_debug_drawstack_setup` height idiom
|
||
(`distance*0.025f`, else `0.05f`); then guarded `render_debug_line_offset`,
|
||
`render_debug_tick`, `ai_debug_render_surface`, `render_debug_point` and two
|
||
`csprintf(... "%.1f" ...)` renders of `node->linear_distance_to_entry_point` and
|
||
`node->cumulative_cost`.
|
||
|
||
`code_0003a910` (704B) builds five `flag ? colour : NULL` values with the
|
||
`neg`/`sbb`/`and` idiom (purple, white, yellow, red, yellow), walks the path's
|
||
hash-table chain via `path_node_from_hash_table`/`path_get_node` calling
|
||
`code_00039990` per node, then makes a second pass over `path->[0x80]` nodes
|
||
skipping any already drawn.
|
||
|
||
### Two blockers before it can be written — neither is guessable
|
||
|
||
1. **`struct path_node` is one field short.** `path.h` declares thirteen members
|
||
summing to exactly **0x32**, and January stamps a `short` at **`node+0x32`**
|
||
(`mov [ebx+0x32], ax` at 0xc2, read back at 0x12d), so the real struct is
|
||
0x34 with an unnamed trailing `short`. It is a per-render generation stamp:
|
||
a910 increments a counter, writes it into every node it draws, and the second
|
||
pass skips nodes whose stamp equals the current counter.
|
||
2. **The counter's identity is unresolved.** It is emitted as
|
||
`inc word ptr [_actor_path_debug_array + 4]`. Our `actor_path_debug_array` is
|
||
a `struct path_debug_storage *`, so `+4` is past the pointer — this is either
|
||
a separate adjacent `static short` that csplit folded into the neighbouring
|
||
symbol (the alias-global-ownership class), or the global is not a pointer in
|
||
January. **Do not guess between those**; resolve it by probing, the way
|
||
`unit_datum+0x6e` was resolved, before writing either function.
|
||
|
||
### `code_00041220`: frame closed, and the residual is exactly two inlines
|
||
|
||
Scoping `iterator` into a block around its own loop — the dying-local rule —
|
||
put `sub esp` on January's **0x8ec exactly** (was 0x8f8) and took the function
|
||
from 2,560 to 2,544. January reuses that slot (`ebp-0x38`) for `origin` in the
|
||
`field_604` block; with `iterator` at function scope we could not.
|
||
|
||
What is left is **precisely two surplus inlines** of `ai_debug_drawstack`, and
|
||
the instruction mix says so with no interpretation required: `+6` each of
|
||
`fld`/`fmul`/`fadd`/`fstp` and `-2 call`, one inline being three of each.
|
||
January calls at three sites and inlines one (the `%d` render); we inline three
|
||
and call one. 208 bytes, which is the whole residual.
|
||
|
||
Measured and rejected this pass:
|
||
|
||
* **Open-coding the drawstack at the site January inlines** — byte-identical
|
||
output, because MSVC was already inlining that site. The two spellings are
|
||
equivalent here, so the call is kept as the simpler one. Note this is *not*
|
||
the code_000383d0 pattern; there the two spellings differed, here they do not.
|
||
* **Three `history_start_time` lifetimes** (initialiser at the declaration; the
|
||
statement before `definition`; the statement before `encounter_get`). None
|
||
produces January's `or edi, 0xffffffff`, and all three move the first
|
||
divergence *earlier* (0x16, 0x2c against the current 0x40). Statement order is
|
||
not the lever.
|
||
|
||
January's `or edi,-1` materialises NONE into a register and stores from it,
|
||
one byte shorter than our direct `mov [ebp-0x3c], 0xffffffff`, and it happens
|
||
because `edi` is free at that instant — January has `definition` in `ebx`, we
|
||
have it in `edi`. That is the same pressure difference that moves the inline
|
||
cutoff, so it is one fact, not two. The inliner is faithful elsewhere in this
|
||
object (`code_00039e80` reproduces January's mixed 3-inline/1-call split from
|
||
uniform source), so the split is a *symptom* of a remaining context difference,
|
||
not something to force.
|
||
|
||
### `ai_debug_render_actor`: the one missing relocation is localised exactly
|
||
|
||
Size is exact at 24,976 and the only multiset difference is `_global_up3d`
|
||
**49 against 48** (every other delta in the list is a jump-table self-reference,
|
||
the known csplit artifact). Aligning the two site lists puts the missing one at
|
||
January **0x1285**, the head of the third marker arm — confirming the entry made
|
||
several sessions ago from a different direction.
|
||
|
||
Both builds load `global_up3d` once at the dominator and once each in arms 1 and
|
||
2; January loads a fourth time at the head of arm 3, we let the dominator's value
|
||
stay live in `eax`. Reading the two streams side by side, **arm 3's instruction
|
||
sequence is otherwise identical** — same order, same operands, differing only in
|
||
frame-slot displacements (January's p0/p1 at `-0x100/-0xfc/-0xf8` and
|
||
`-0xf0/-0xec/-0xe8`, ours at `-0xc0/-0xbc/-0xb8` and `-0x80/-0x7c/-0x78`).
|
||
|
||
So the residual is one register-pressure decision plus the slot permutation it
|
||
implies, and the slot permutation was already shown not to respond to
|
||
declaration order. This is the same class as `code_00041220`'s inline split, and
|
||
the two may well share a cause.
|
||
|
||
### Correction: both path-chain blockers are softer than recorded above
|
||
|
||
Re-checked after the string-oracle session landed. The entry above overstated
|
||
both, and in one case got the arithmetic wrong.
|
||
|
||
**`path_node+0x32` is tail padding, not a field past the end.** The thirteen
|
||
declared members sum to 0x32, but the struct contains `long`s and `real`s, so
|
||
4-byte alignment already makes `sizeof(struct path_node)` **0x34**. The stride
|
||
is therefore *already correct* and nothing downstream moves. Naming the two
|
||
padding bytes as a `short` is a zero-risk addition — it cannot change `sizeof`,
|
||
any other member's offset, or `path.c`'s codegen. No probe is needed; the
|
||
earlier "resolve by probing before writing" note was wrong.
|
||
|
||
**The counter has no worked example, and that is itself the answer.** Sweeping
|
||
every relocation to `_actor_path_debug_array` across all 467 target units:
|
||
twenty sites, and **every one outside `code_0003a910` uses addend 0** — the
|
||
pointer itself, in seven functions that are already exact. Only a910 uses
|
||
addend 4, four times. So there is no exact function to read the name off, and
|
||
the +4 cannot be a member of the pointer.
|
||
|
||
That makes it the alias-global-ownership shape: a `static short` declared
|
||
immediately after `actor_path_debug_array`, which csplit folded into the
|
||
neighbouring symbol because both live in the same zero-initialised region. The
|
||
test is cheap and does not require guessing the name — `relocation_infos_equal`
|
||
matches defined non-code relocations by **proven section-relative destination**,
|
||
not by symbol spelling, so a `static short` declared in that position will
|
||
compare equal to January's `+4` if it lands at the same offset. Declare it with
|
||
the file's `field_XXX`/descriptive convention, write a910, and the comparator
|
||
adjudicates it.
|
||
|
||
**What actually remains for the chain is the writing**, roughly 1,550 bytes
|
||
across the two statics, not a data-model recovery. `code_00039990`'s twelve
|
||
parameters and its call order are already tabulated above; `code_0003a910`'s
|
||
two passes are described; `struct path_node` and both `path_*` helpers are
|
||
already declared in `path.h`/`path.c`.
|
||
|
||
**The string oracle does not help here** — `synopsis` mode returns only `"%.1f"`
|
||
for `code_00039990` and nothing for `code_0003a910`, because neither carries an
|
||
assert. The name-oracle effect needs stringized expressions; format strings
|
||
alone do not name identifiers.
|
||
|
||
## The path chain: render_path_storage exact (56/60), both statics written to size
|
||
|
||
**The data-model recovery came first and was the whole game.** January's own
|
||
`_path_node_from_hash_table` bytes state the node stride: `imul edi, edi, 0x44`.
|
||
`struct path_node` was 0x34 — three fields short. The recovered tail:
|
||
`short last_render_id` @0x32, `real closest_distance_to_attractor` @0x34,
|
||
`real_point3d closest_point_to_attractor` @0x38 — 0x44 exactly, zero padding.
|
||
From the same function: node_count @0x80, node_list @0x84, hash_table @0x1208A
|
||
(and the hash is `(surface & 0x1FF)*8` wrapped at 0xFFF — the header's
|
||
PATH_HASH_TABLE_SIZE_PER_KEY and PATH_HASH_TABLE_SIZE constants, confirmed).
|
||
sizeof(struct path_state) is therefore 0x1408C — exactly 0x4000 more than the
|
||
header claimed, and both headers carried exactly 0x4000 of unknown blob
|
||
immediately after their path_state member (`__unknown5C8A8[16384]` in
|
||
ai_debug.h, and 0x4000 of `__unknown100A0[0x405C]` in path.h). Deleting the one
|
||
and shrinking the other kept every existing offset assert green. The avoidance
|
||
tail followed by arithmetic: `short avoidance_path_count` @0x1458A, four
|
||
0xC08-stride obstacle records @0x1458C, four 0x1534-stride path records
|
||
@0x175AC, ending at 0x1CA7C — the asserted struct end, no residue.
|
||
|
||
**Both statics landed at exact size and relocation count on the first build**
|
||
(848/42, 704/23), and `ai_debug_render_path_storage` closed after three fixes:
|
||
a `0.0` double literal where January compares `0.f` (qword vs dword fcomp —
|
||
found by the byte diff, the exact class house_rules warns about), the missing
|
||
avoidance/nodes tail blocks, and the trailing
|
||
`path->last_render_id = ai_debug.last_render_id;`.
|
||
|
||
Recoveries measured on the way:
|
||
- 39990's entry pointer is the copy-before-bind shape *again*:
|
||
`position.x` computed through `node->entry_point.x`, then
|
||
`entry_point = &node->entry_point;` bound, y/z and all later uses through it.
|
||
- The camera-height idiom needs the **caller-named** `v`:
|
||
`magnitude3d(vector_from_points3d(&position, &camera->position, &v))*0.025f`.
|
||
`distance3d(...)` — identical meaning, callee-owned v — transposes the dot
|
||
product's j/i products at BOTH sites, and the two sites are IR-coupled:
|
||
respelling either one alone regresses the other.
|
||
- a910's three-arm point selection feeds ONE call through a
|
||
`real_point3d const *point` variable (January keeps each arm's lea separate;
|
||
an argument ternary lets VC7 tail-merge and duplicate the call setup).
|
||
Arm layout is `if (!previous_node) {...} else point = &node->entry_point;` —
|
||
the else out-of-line at the end.
|
||
- The centroid average is THREE DIVISIONS, `centre.x /= vertex_count;` etc:
|
||
VC7's own reciprocal CSE produces the single `fild/fdivr 1.0` and symmetric
|
||
multiplies. A named `scale = 1.f/count` gives the same arithmetic but lets
|
||
the peephole fold the last multiply.
|
||
|
||
**What remains, honestly.** 56/60, 14,128/42,992 bytes. The four residuals are
|
||
all in the register/scheduling class this object keeps producing:
|
||
|
||
1. `code_00039990` — 845/848 bytes, ONE transposition (`fmul [eax]`/`push edx`
|
||
swapped at 0x229). Nine forms measured and rejected: nested call, shared v,
|
||
scoped v ×3, member-wise copy, commuted sum, point-via-copy. R4/0x349 class.
|
||
2. `code_0003a910` — pass 2's walker allocates EAX where January uses EBX
|
||
(cascading encodings), plus the R4 z-fold: five spellings of the scale
|
||
(named/braced, named/arm, reciprocal ×3, divisions ×3, scale_vector3d with
|
||
the render_actor cast idiom) all let VC7 fold the last multiply that
|
||
January keeps unfolded with a discard pop.
|
||
**Plus a comparator-policy blocker:** January relocates the render counter
|
||
as `_actor_path_debug_array+4` — an addend into the middle of a 4-byte
|
||
pointer, impossible as a genuine reference. The aggregate reading is
|
||
falsified by January's own stringized asserts (`"actor_debug_array &&
|
||
actor_path_debug_array"` — not valid over a struct), so the original had
|
||
two adjacent objects and csplit folded the anonymous static into the
|
||
neighbouring exported symbol. Our `static short global_ai_debug_path_render_id`
|
||
declared adjacent is the faithful source; equating the two relocation
|
||
spellings needs an adjudicated-alias mechanism in the comparator, which is
|
||
a policy decision, not a source question. (The aggregate WAS measured: it
|
||
regressed initialize/clear_storage through their assert strings — 54/60 —
|
||
and was reverted.)
|
||
3. `code_00041220` — two surplus drawstack inlines, +208, as previously logged.
|
||
4. `ai_debug_render_actor` — one `global_up3d` reload plus the slot permutation.
|
||
|
||
### The counter question resolved by existing precedent, not new policy
|
||
|
||
Ruling from the top: everything must eventually byte match, and no assembly.
|
||
Both are satisfiable here, and the machinery already exists.
|
||
|
||
`tools/audit_semantic_matches.py` states the project's own standard: symbol
|
||
ownership can legitimately differ between csplit output and the rebuilt object
|
||
when size, normalized bytes, relocation addresses and relocation types all
|
||
agree — `config/semantic_data_matches.json` carries twenty such admissions,
|
||
each with the resolved-image-destination evidence written down. The a910
|
||
counter is that class exactly.
|
||
|
||
What remained was proving the image destination. VC7 groups statics ahead of
|
||
externals in `.bss`, so the `static short` landed at 0x40 — *before* the
|
||
pointer — and the linked bytes would have differed. January's counter was
|
||
therefore non-static. Respelled `short global_ai_debug_path_render_id = 0;`
|
||
immediately after the pointer definition: `.bss` now places
|
||
`_actor_path_debug_array` at 0x44 and the counter at 0x48 — **pointer+4,
|
||
January's exact destination**. Once a910's code bytes are exact, it enters the
|
||
ledger with this evidence; the comparator itself stays untouched.
|
||
|
||
Remaining to 60/60, all C-source questions, none needing assembly:
|
||
39990's one transposition, a910's walker register + z-fold, 41220's two
|
||
drawstack inlines, render_actor's reload. All four are the R4/scheduling
|
||
class; the session's rejected-form tables above are the starting inventory.
|
||
|
||
## The counter closed by construction, and the comparator completed, not bypassed
|
||
|
||
Ruling applied: everything must eventually byte match. Three measured facts
|
||
had seemed irreconcilable — the a910 head schedule needs the render counter
|
||
static (the `destination_valid` load hoists across the store only when no
|
||
pointer can alias it); the counter must live at `actor_path_debug_array+4`
|
||
(January's `.bss` is 0x4A long and the two anonymous bytes at 0x48 are
|
||
literally the counter's storage, readable from the carved object's symbol
|
||
table: bit_vector@0, debug@0x40, path@0x44); and statics seemed to precede
|
||
externals in `.bss`.
|
||
|
||
The third fact was mis-measured. VC7 groups `.bss` by INITIALISATION, not by
|
||
linkage: uninitialised objects first, then explicitly-zero-initialised objects
|
||
in declaration order. Three layout experiments established this (and falsified
|
||
both the statics-first and reverse-declaration-order readings). The recovered
|
||
spelling is therefore:
|
||
|
||
struct actor_debug_info *actor_debug_array = NULL;
|
||
struct path_debug_storage *actor_path_debug_array = NULL;
|
||
static short global_ai_debug_path_render_id = 0;
|
||
|
||
— arrays extern as before (making them static shrinks actor_deleted and
|
||
code_00039060 by 16 bytes each: alias freedom changes their codegen, which
|
||
falsifies static arrays outright), counter static AND explicitly `= 0` so it
|
||
joins the initialised group after them. Our `.bss` now reproduces January's
|
||
byte for byte, 0x4A and all offsets.
|
||
|
||
With the layout identical, the relocation question stopped being policy.
|
||
`coff_compare` already proves defined-data destinations by anchor+delta; it
|
||
just never carried the resolved offset. It now records
|
||
`resolved_destination = [section, symbol_value+addend]` and
|
||
`relocation_infos_equal` accepts equality of resolved destinations as a
|
||
FALLBACK-ONLY path — it can only fire after both existing proofs fail, so it
|
||
is monotone over every prior verdict (all 44 comparator tests pass, tree
|
||
score unchanged). Under it, January's `_actor_path_debug_array+4` and our
|
||
`_global_ai_debug_path_render_id+0` both resolve to `('.bss', 0x48)` and
|
||
compare equal — the same arithmetic the comparator already trusted at
|
||
addend 0, extended to nonzero addends. No ledger entry, no alias table.
|
||
|
||
a910's residual is now code bytes only: the pass-2 walker register and the
|
||
R4 z-fold. Address drift in five relocations follows those bytes and is not
|
||
an identity problem.
|
||
|
||
## tools/emission_shape_oracle.py
|
||
|
||
Built this session: corpus retrieval by emission shape. Query = a window of
|
||
target disassembly (unit, function, byte range); instructions normalise to
|
||
shape tokens (x87 stack positions and small immediates kept, displacements
|
||
classified, registers wildcardable); matches report (unit, function, offset)
|
||
across the 3,332-function exact corpus so the proven source can be read.
|
||
|
||
First results, calibrating honestly: the a910 kept-reciprocal query returned
|
||
five worked examples in seconds, and reading vehicle_stuck's proven source
|
||
falsified two of my theories about the fold (it uses the exact named-scale
|
||
decl-init spelling that folds in a910 — so the fold's lever is contextual,
|
||
not the scale's spelling). The 39990 transposition query returned zero hits
|
||
at every width — like 37fa0's quad, the shape is unique in the corpus, which
|
||
is itself the answer to "why did no worked example surface".
|
||
|
||
## Context walk on the last four: what it closed and where it stopped
|
||
|
||
The a910 residual is now fully accounted, byte by byte. The whole 4-byte
|
||
branch-target drift is the z-fold (3 bytes) plus one; the 0xf3-0x124 head
|
||
cluster is a pure encoding swap (walker in EAX against January's EBX, zero
|
||
length difference); the tail relocation drift follows those bytes. Twelve
|
||
spellings of the scale and two for-init fusions are now measured against the
|
||
fold, including the exact named-scale decl-init that vehicle_stuck proves and
|
||
both orderings of the point binding under the corrected arm nesting. None
|
||
move it. The walker register survives eleven placement/scoping forms.
|
||
|
||
Both residual classes sit in allocator/scheduler state that no measured local
|
||
edit reaches — the same verdict the woutput and telnet_console campaigns
|
||
recorded for this class, but now with the search space documented rather than
|
||
presumed. What distinguishes these from the ties that DID fall this week
|
||
(0x349, the update guard, the 37fa0 anchor): those had a nameable source
|
||
construct upstream; these four have byte-identical or encoding-only contexts
|
||
in every direction the project knows how to walk.
|
||
|
||
State at close: 56/60 sections, 14,128/42,992 bytes. The four:
|
||
39990 845/848 (one transposition, corpus-unique shape, nine forms),
|
||
a910 (fold + walker register, twelve + eleven forms, relocation identity
|
||
fully proven including the counter), 41220 (two drawstack inlines, the
|
||
inliner faithful elsewhere from uniform source), render_actor (one reload,
|
||
one slot permutation, declaration-independence proven). Every rejected form
|
||
is tabulated above; the next instrument should compare CONTEXTS between the
|
||
corpus's nearest-shape functions and these sites, not spellings within them.
|
||
|
||
## tools/context_diff_oracle.py, and its first measurement
|
||
|
||
Built as the companion to the shape oracle: given two (function, window)
|
||
pairs, it reports x87 depth at entry, address materialisations before the
|
||
window, nearest-call distances, and instruction-mix deltas on both sides.
|
||
|
||
First run, on the fold pair (vehicle_stuck 0xe5-0xff against January a910
|
||
0x201-0x21b): depth identical (1), both windows preceded by an address
|
||
materialisation of the multiply target, and ONE structural delta —
|
||
vehicle_stuck's multiplies sit inside a call's argument construction
|
||
(call immediately after, three argument pushes interleaved before), while
|
||
a910's window is call-free in both directions. So a pending call frame is
|
||
sufficient to keep the reciprocal unfolded but January proves it is not
|
||
necessary; the true discriminator is narrower and remains unidentified after
|
||
twelve spellings and this context measurement. Recorded as the open question
|
||
that decides a910, with the instrument now in the tree to answer it against
|
||
the other four kept-reciprocal corpus examples.
|
||
|
||
### The intersection run: the fold question inverted
|
||
|
||
Context-diffing January's a910 window against all four clean kept-reciprocal
|
||
corpus sites (code_001a6710, code_001a6dc0, vehicle_stuck, render_sky):
|
||
|
||
| site | after the pop | address escape before | call adjacency |
|
||
|---|---|---|---|
|
||
| code_001a6710 | call +5 | yes | yes |
|
||
| code_001a6dc0 | call +5 | yes (three) | yes |
|
||
| vehicle_stuck | call +0 | yes (pushed as arg) | yes |
|
||
| render_sky | more x87 | none | none |
|
||
| January a910 | jmp to shared call | yes (point = ¢re) | none |
|
||
|
||
No property survives the intersection: not call adjacency (render_sky, a910),
|
||
not address escape (render_sky), not continued x87 (the vehicles trio). The
|
||
finding is therefore the inversion: **five of five January sites keep the
|
||
reciprocal; the fold appears in no exact function in the corpus, under any
|
||
context.** It is not January declining a peephole at this site — it is our
|
||
rebuild applying one that the January toolchain, on this unit's flags, never
|
||
produced. Twelve source spellings all fold in our build, including the two
|
||
spellings the corpus proves (vehicle_stuck's named decl-init scale, and the
|
||
plain divisions).
|
||
|
||
That reframes the open question from "what source keeps the value live" to
|
||
"what makes our compilation fold at all, here and nowhere else" — a
|
||
flag-provenance-shaped question (the periodic_functions /Op precedent: a
|
||
site unmatchable at any source shape, with the cause in the per-unit flags),
|
||
except ai_debug's other 56 sections match under the configured flags, so a
|
||
whole-unit flag difference is excluded. The narrowed suspects, for next
|
||
session: the surrounding FUNCTION's flag-sensitive content (a910 is new code
|
||
— every corpus example lives in a function matched under known-good shapes),
|
||
and the possibility that some other construct in our a910 puts the optimizer
|
||
in a state the corpus functions never reach. The instrument to settle it:
|
||
compile the corpus spelling in a MINIMAL clone of a910 (strip statements
|
||
until the fold disappears) — a bisection over our own source, which no
|
||
oracle is needed for.
|
||
|
||
### The fold bisection: four suspects eliminated, one region left standing
|
||
|
||
Independent single-statement deletions over a910, each rebuilt and the
|
||
emission checked for the fold signature after the fdivr:
|
||
|
||
| probe | result |
|
||
|---|---|
|
||
| pass 1 deleted entirely | still folds |
|
||
| colour ternaries flattened to plain globals | still folds |
|
||
| copy-arm (closest_point) deleted | still folds |
|
||
| walk tail (stamp/parent/prev) deleted | still folds |
|
||
| edge do-while replaced (both attempts) | probe invalid — without the loop the divide itself transforms differently (constant-folded once, no reciprocal once), so the fold question cannot be posed |
|
||
|
||
So the fold's cause is not in pass 1, the colour setup, the sibling arms, or
|
||
the walk plumbing. What survives elimination is the do-while edge walk itself
|
||
and its interaction with the divide that consumes its count — precisely the
|
||
region the invalid probes could not cleanly remove. Next session's probe must
|
||
keep a genuine loop and vary only its body (e.g. a single trivial accumulate)
|
||
so the reciprocal transform still fires; if the fold then disappears, the
|
||
lever is inside the loop's IR footprint, and the corpus comparison point is
|
||
vehicle_stuck's separately-blocked loop-then-guarded-divide structure against
|
||
our loop-then-straight-line divide.
|
||
|
||
## The walker register falls to statement order; the fold detector corrected; January folds elsewhere
|
||
|
||
**Detector correction first, because two of this session's early rounds were
|
||
invalidated by it.** The prior fold check read a fixed nine-instruction window
|
||
after the fdivr; a genuine keep is exactly nine instructions before its
|
||
discard pop, so a keep whose window was padded by scheduled pushes classified
|
||
as FOLDED. The corrected detector (`folddet.py` in the session temp dir,
|
||
recreate from this description) walks forward from the fdivr to whichever
|
||
terminator comes first - `fstp st(0)` means UNFOLDED, a memory-operand `fmul`
|
||
means FOLDED - and is calibrated on January a910 (UNFOLDED) against our build
|
||
(FOLDED). Every fold verdict below used the corrected detector; the prior
|
||
sessions' in-unit verdicts were re-run and all CONFIRMED (the thirteen
|
||
spellings genuinely fold in-unit).
|
||
|
||
**The corpus inversion was an artifact of the corpus.** A census over all 833
|
||
split targets - fdivr through a `__real@3f800000` relocation with a fild
|
||
within six instructions - finds January itself FOLDS at twelve sites and
|
||
keeps at twelve. The two folded sites with a two-multiply run
|
||
(code_000109a0 in actor_combat 0x699, code_00177860 in render_contrails
|
||
0x6f3) are three-component scales exactly like a910's - January folds the
|
||
last multiply there. Reading all sites' contexts gives January's empirical
|
||
rule: a count ACCUMULATED BY A LOOP keeps the reciprocal (vehicle_stuck,
|
||
build_structure_lens_flares, a910); a count arriving from a register or a
|
||
struct field folds (actor_combat, render_contrails). So the fold is ordinary
|
||
context-dependent behaviour with worked examples on both sides in January,
|
||
not a toolchain impossibility.
|
||
|
||
**What the lab established** (scratch-TU probes at the unit's flags,
|
||
vehicle_stuck transplanted verbatim reproduces its keep, byte shape and
|
||
all): a counted-exit loop before the divide keeps; a data-dependent
|
||
(chase) exit folds, under every spelling of the loop (do-while, for(;;)+break,
|
||
while(1)+break, goto), every divide spelling (divisions, braced/arm named
|
||
scale, reciprocal x3, scale_vector3d, operand orientations), every counter
|
||
declaration/init/increment shape, and both union member spellings. Flags are
|
||
excluded by an eighteen-way whole-unit matrix (/QIfist removal, /Ow, /Oa,
|
||
/Ob1, /Os, /Op, /G3-/G7, /GB, /Gy, /Z7, /Zi, /Gf, /Ge, /Gh): nothing unfolds
|
||
a910, including flags that wreck 45+ other sections. TU-cumulative state is
|
||
excluded by a minimal-TU probe (all bodies outside a910's static call tree
|
||
stubbed, extern root added): still folds at size 704. The one cell with no
|
||
worked example anywhere is chase-exit-plus-keep - which is precisely
|
||
January's a910. The discriminator remains open; it is a source fact, not a
|
||
flag or a neighbour.
|
||
|
||
**The walker register was never a register question.** Reading January's
|
||
store order at the pass-2 walk tail: January stores `previous_node` BEFORE
|
||
loading the walker (`mov [ebp-8],ebx` then `mov bx,[ebx+2]`), so the walker
|
||
is reborn into node's own register - the two live ranges tile. Our source
|
||
ordered `node_index = node->parent_node_index;` before `previous_node =
|
||
node;`, keeping node live across the walker's rebirth and forcing the walker
|
||
into EAX, which cascaded through the whole pass-2 head (the early
|
||
collision_bsp spill at 0xf3, the `cmp ax` accumulator-form encoding, the
|
||
argument push). The recovered pass-2 tail order is
|
||
|
||
node->last_render_id = global_ai_debug_path_render_id;
|
||
previous_node = node;
|
||
node_index = node->parent_node_index;
|
||
|
||
- pass 1 keeps the original order (its bytes already matched; the
|
||
two-sites-two-spellings rule again). One swap: head `xor ebx,ebx` exact,
|
||
tail exact, cmp encoding exact, 173 -> 151 non-relocation diff bytes, unit
|
||
still 56/60 at 14,128. The eleven rejected placement/scoping forms were
|
||
sweeping the declaration; the lever was the statement order at the tail.
|
||
Escape order (`point = ¢re` before the divides) was also probed on top
|
||
of the swap: normalised away, no effect.
|
||
|
||
**a910's sole residual is now the fold** - 151 non-relocation bytes, all the
|
||
fold plus its branch-displacement echoes. The divide spellings were re-swept
|
||
on top of the corrected walker (the IR numbering shifted): all still fold.
|
||
|
||
### Post-walker negatives, recorded so they are not re-run
|
||
|
||
On top of the corrected walk-tail order, each measured with the corrected
|
||
detector and whole-section diffs:
|
||
|
||
- a910 divide spellings re-swept (divisions, braced scale, reciprocal x3,
|
||
scale_vector3d): all still fold, 151 non-reloc bytes each.
|
||
- `point = ¢re` moved before the divides (January's schedule shows the
|
||
lea hoisted; the intersection table's "address escape before"): normalised
|
||
away, no byte change.
|
||
- **Per-arm 39990 calls** (the cross-jump form - call written inside each of
|
||
the three arms, hoping VC7 tail-merges the setups and the divides share a
|
||
block with a call in IR, like every January keep site with memory-homed
|
||
accumulators): VC7 does NOT merge them - 784 bytes (+80), still folds.
|
||
The point-variable join is the right source; rejected decisively.
|
||
- 39990's transposition at 0x229: shared function-scope `v` and
|
||
attractor-block-scope `v` (removing one arm-scoped declaration each) - no
|
||
effect, 3 bytes; a named `text_height = point_height+0.15f` local - worse
|
||
(7 bytes). The slot is an IR-id scheduling tie; the id-shifting construct
|
||
has not been found.
|
||
- render_actor's reload read from the bytes: January reloads the
|
||
`global_up3d` pointer at the third marker arm's head (0x1284) after calls
|
||
clobber globals; our build keeps the loaded value alive across the
|
||
arm boundary in a callee-saved register. The lever is a lifetime fact in
|
||
the marker-arm region (three structurally-similar arms at source
|
||
1233/1254/1277) - the arms' internal spellings need the mixed-spelling
|
||
treatment against January 0x11xx-0x13xx before touching registers.
|
||
|
||
The fold's standing frame after this session: VC7 at these flags keeps the
|
||
reciprocal after counted-exit loops and folds it after chase-exit loops, in
|
||
every reachable IR (lab, in-unit, minimal-TU, 18 flag configurations).
|
||
January's a910 is the one chase-exit keep anywhere in the January image.
|
||
Until an instrument produces a second occupant of that cell - or a worked
|
||
example of a chase-exit keep from any source shape - further unguided
|
||
spelling sweeps are not justified.
|
||
|
||
### 41220: the inline split responds to guards; the multiset pins the whole residual
|
||
|
||
Measured this session, all with whole-unit scoring:
|
||
|
||
- The relocation multiset delta between January and ours is EXACTLY the two
|
||
surplus drawstack inline bodies (per-inline signature 6x last_position,
|
||
7x next_position, 3x height, 1x up3d; January 1 inline, ours 3). Nothing
|
||
else in 2,336 bytes differs - the function is otherwise recovered.
|
||
- Site map (drawstack sites at source 5776/5796/5844/5849): January
|
||
call/inline/call/call; ours inline/inline/call/inline.
|
||
- **A guard around a site moves the inliner.** `if (color)` around site 4
|
||
turned it into a call (2544 -> 2448). But VC7 does NOT fold the never-null
|
||
test (a phi of address-of-globals still emits `test edi,edi / je`), so
|
||
that exact form is out - January's site 4 has no test. The mechanism
|
||
stands: the decision is per-site and context-driven; the byte-invisible
|
||
construct that deepens sites 1 and 4 in January's source is the open
|
||
question.
|
||
- `point_from_line3d` respelling of the pre-setup bump (source 5762-5764):
|
||
byte-identical AND decision-identical - inliner budget is not consumed by
|
||
one small helper inline.
|
||
- January's site-1 region read in full: the two consecutive
|
||
`if (selected_actor_index!=NONE)` blocks are genuinely two sibling ifs -
|
||
both tests exist and VC7 jump-threads the first test's false edge past
|
||
both blocks (0x610 je 0x683). Our spelling is confirmed as-written.
|
||
|
||
render_actor's reload, read but not yet attacked: January reloads the
|
||
global_up3d pointer value at the third marker arm's head (0x1284); ours
|
||
carries the loaded value across the arm boundary in a callee-saved register.
|
||
The marker arms (source 1233/1254/1277) need the mixed-spelling treatment
|
||
against January 0x11xx-0x13xx - a lifetime question, not a register one.
|
||
|
||
## render_actor mapped by lockstep; a semantic csprintf defect fixed
|
||
|
||
**New instrument: `tools/lockstep_slot_compare.py`** - walks two disassemblies
|
||
in parallel with frame-slot displacements wildcarded into a growing bijection;
|
||
reports shape mismatches, bijection conflicts (slot-sharing differences), and
|
||
resyncs across single-instruction insertions. Masks relocations before
|
||
disassembly per house rules.
|
||
|
||
**Fixed and kept: the "%s%s" argument order at source line 770.** January
|
||
pushes blocking_mode last: its source is `csprintf(temporary, "%s%s",
|
||
blocking_type, blocking_mode)` - ours had the two reversed, which is a
|
||
SEMANTIC transcription defect (the rendered string was reversed), invisible
|
||
to size and multiset because both arguments are pushed either way. Found as
|
||
the first lockstep shape mismatch at 0x234; the site now matches.
|
||
|
||
**The render_actor residual re-measured honestly** (the prior "size exact"
|
||
note is stale): January 24,976 / ours 24,992 (+16), jan 6,869 instructions /
|
||
ours 6,855 (JANUARY HAS 14 MORE - reloads we lack, only one of which is
|
||
relocation-visible, the up3d count). Ours keeps values in registers and in
|
||
private frame slots where January reloads and shares slots: the bijection
|
||
conflicts show January packing three lifetimes into -0x2c (an early swarm
|
||
walk counter, a ray-loop induction temp, a later pointer) and two into
|
||
-0x30 (ray temp, then the [i][j] loop's i), while ours gives each a fresh
|
||
slot. The slot map diverges from the FIRST spill (actor at jan -0x104 /
|
||
ours -0x170), so the layout order differs globally; the +16 bytes are
|
||
disp8/disp32 growth downstream of layout. The lever the map points at is
|
||
lifetime/scope structure around the vector-avoidance region, not any single
|
||
declaration.
|
||
|
||
Measured and rejected on the way:
|
||
- default-arm switch spelling for the [i][j] block's `color` (ladder
|
||
polarity comes out wrong - je/je against January's je/jne; the original
|
||
decl-init ladder is correct and the early-load/spill of white is a
|
||
pressure symptom, not a spelling defect);
|
||
- scope-disjoint brace block for `short i; short j;` (no byte movement);
|
||
- swapped point/vector arguments at the t=1.f point_from_line3d (moves the
|
||
strength-reduction anchor: fadd [edi+0x68] appears, January has
|
||
[edi-4] - January keeps origin-first arguments with a load-hiding
|
||
commute on the x component only, our remaining 3-byte site at 0x46b).
|
||
|
||
41220 addendum: caller-side mass probes (16 junk statements), do-while(0),
|
||
csprintf hoisting, result casts, parenthesized callee all leave the split
|
||
untouched; a one-statement callee-mass increase overshoots (all four sites
|
||
become calls AND 39e80/3a2e0 regress), so the callee body is pinned from
|
||
both directions and the split's discriminator remains open.
|
||
|
||
## render_actor: the residual localized to one upstream scheduling shift
|
||
|
||
Second lockstep pass (tools/lockstep_walk.py - full-function walk with
|
||
extra/missing-instruction census and resync). Measured facts:
|
||
|
||
- **The frame layout difference is ONE GROUP SWAP.** The deep aggregates
|
||
(-0x2b0..-0x810) map identically. In the scalar zone, Group X = {actor
|
||
spill, path spill, a six-dword cluster first used at 0x59xx} and Group Y =
|
||
{the secondary-look string-table arrays, first used at 0x25xx} are each
|
||
CONTIGUOUS on both sides but in opposite order: January X-then-Y
|
||
(actor at -0x104), ours Y-then-X (actor at -0x170). The +16 bytes are
|
||
disp8/disp32 growth downstream of this swap.
|
||
- **Declaration order of the string tables is inert** (all six permutations
|
||
byte-identical - the house-rule holds for aggregates too).
|
||
- **The 14 missing instructions pair up as copy-pipeline choices**: at all
|
||
six throttle sites (source 1936-1946 and 3103-3111) January materializes
|
||
the point_from_line3d t-argument as an x87 copy (fld [esi+0x6e4/8] /
|
||
fstp scratch) where ours emits integer movs; a named block-scoped
|
||
`real t` does NOT flip it (allocation state, not spelling). Plus the
|
||
up3d reload and companions in the marker-arm region.
|
||
- **Longhand respelling of the ray-block point1** (t=1.f fold site) is
|
||
REJECTED despite matching January's byte size exactly (24,976): the
|
||
instruction delta worsens 14 -> 23. Size alone is a misleading gate here;
|
||
use the instruction census.
|
||
- The 0x46d3 "moved csprintf" was a resync artifact across the desynced
|
||
0x46xx-0x4cxx stretch, not a moved statement.
|
||
- **The first genuine divergence is now bounded to the head-marker region
|
||
(bytes ~0x10xx-0x1160, source ~1150-1230)**: by 0x1160 ours has already
|
||
computed the 0.2f x-component that January computes at 0x1160, and every
|
||
later symptom (0x46b commute, color registerization, throttle copy
|
||
pipeline, up3d reload, layout swap) is downstream allocation state. The
|
||
next session should align 0x1020-0x1160 with slot labels and read the
|
||
first free-order difference the way the a910 walk tail was read.
|
||
|
||
The csprintf argument-order fix from the previous pass stands (committed);
|
||
unit remains 56/60, 14,128 bytes throughout.
|
||
|
||
### The head-marker alignment: first divergence isolated to one R4 tie
|
||
|
||
The 0xfe0-0x10a8 window is now fully read on both sides. It is the
|
||
cluster==NONE marker arm (source 1233-1252): offset_vector copied by
|
||
integer movs (matches), the ultimate-parent/cluster test (matches), then
|
||
p0 = base + offset*0.1f and p1 = base - offset*0.1f with the three products
|
||
CSE'd across the +/- pair on the x87 stack, then p2/p3 = +/-point + up*0.2f
|
||
(matches). Everything before 0x1046 matches modulo slot digits.
|
||
|
||
**The first genuine divergence of the whole function is the p0.z add at
|
||
January 0x1046:**
|
||
|
||
January: fld prod.k / fmul 0.1f / FST [-0x1c] / fadd base.z / fstp p0.z (5 instrs)
|
||
ours: fld prod.k / fmul 0.1f / FSTP [-0x1c] / fld base.z / fadd [-0x1c] / fstp p0.z (6)
|
||
|
||
Both spill the z-product for p1.z's later fsub; January's add consumes the
|
||
still-stacked product (non-popping fst), ours reloads through memory. This
|
||
is the SAME consume-the-dying-stack-value axis as a910's fold - with the
|
||
tie-break flipped the OPPOSITE way (there our build consumes, January
|
||
keeps; here January consumes, our build keeps). One instruction of the
|
||
14-count sits right here; the rest of the region's differences are
|
||
downstream allocation state.
|
||
|
||
Measured inert or wrong at this site:
|
||
- operand swap `offset_vector.k*0.1f+base_point.z` - canonicalized,
|
||
byte-identical;
|
||
- naming the three products (`real dx/dy/dz`) - function shrinks 64 bytes
|
||
(more sharing), no fst; rejected.
|
||
|
||
Standing conclusion: render_actor's residual is a chain of R4-class
|
||
instruction-selection ties whose first link is this p0.z site, and the
|
||
project's evidence (a910's forty-probe history on the same axis) says such
|
||
ties do not yield to local respelling. The two objects' opposite tie-break
|
||
directions on the same axis in one TU is the sharpest clue yet recorded
|
||
about the upstream IR-numbering difference; the shared real_math inline
|
||
bodies are the one TU-wide input class not yet audited, but any probe there
|
||
must gate on the whole exact corpus, not this unit alone.
|
||
|
||
## The real_math inline-body audit: the header is exonerated
|
||
|
||
Instrument: a thirteen-unit sentinel gate (the heaviest inline users -
|
||
ai_debug, vehicles, collision_features, units, real_math, breakable_surfaces,
|
||
structure_lens_flares, players, render_sky, collisions, collision_bsp,
|
||
objects, action_charge; 491 exact sections at baseline), scoring every
|
||
variant of a shared body against all of them plus the four ai_debug residual
|
||
metrics. Harness in the session temp dir (auditreal.py), reconstructable
|
||
from this description.
|
||
|
||
**Front-end-normalized (bit-identical everywhere, so neither defect nor
|
||
lever):** operand commutes of point_from_line3d's sum and product, of
|
||
scale_vector3d's product; flattening magnitude_squared3d into magnitude3d;
|
||
a named intermediate inside magnitude3d.
|
||
|
||
**Corpus-pinned (every IR-visible variant regresses exact functions):**
|
||
- removing the square_root wrapper (direct sqrt): 3 units regress - the
|
||
wrapper's parameter-binding layer is REQUIRED;
|
||
- copying the point parameter inside point_from_line3d: 5 units;
|
||
- magnitude3d or magnitude_squared3d via dot_product3d(v, v): 10-11 units;
|
||
- cross_product3d without its reversed k/j/i temps, or with them reordered
|
||
i/j/k: 3-4 units.
|
||
|
||
**Verdict: no reachable respelling of the shared bodies can flip the four
|
||
residuals' ties without breaking pinned functions.** The upstream
|
||
IR-numbering difference behind the tie-flips does not live in real_math.h.
|
||
|
||
**Precompiled headers also excluded:** /Yc"cseries.h", /Yu"cseries.h", and
|
||
/YX all produce bit-identical objects to the non-PCH compile (56/60 and all
|
||
four residual byte counts unchanged). The PCH axis is dead.
|
||
|
||
Standing state of the hunt for the four ties: flags (18-way), processor
|
||
tuning, debug info, PCH, TU-neighbour content (minimal-TU), shared inline
|
||
bodies, and every local spelling class are all excluded by measurement.
|
||
What remains is per-function source structure in forms not yet conceived;
|
||
the sharpest recorded clue is the pair of opposite tie-breaks on the same
|
||
consume-vs-memory axis (a910's fold vs render_actor's p0.z) inside one TU.
|
||
|
||
### The consume-vs-memory axis, chased to its mechanical statement
|
||
|
||
The two opposite tie-breaks (a910's fold, render_actor's p0.z) were pursued
|
||
through lab reproduction, scope probes, and a corpus census. Results:
|
||
|
||
- **Wide-scope named scale refuted for the fold's discard pop**: `real
|
||
scale;` at pass-2 block scope and at function scope both build
|
||
byte-identically to the arm-scoped form (still folds, 151). VC7's x87
|
||
lifetimes follow dataflow death, not lexical scope.
|
||
- **The standalone p0/p1 shape emits a third form** (per-component
|
||
`fld st(0)` duplication, no spill) - the in-context spill is
|
||
pressure-created and the tie cannot be posed in a small TU.
|
||
- **Upstream free statement orders in render_actor's marker head are all
|
||
byte-pinned** (call sequence and branch polarity fix them), so the p0.z
|
||
tie has no reachable source lever on the walker precedent's pattern.
|
||
- **Corpus census of the two forms** (11 consume sites, 3 apparent reload
|
||
sites - the reload matches are suspect: the census compared
|
||
relocation-masked operands, so distinct globals alias as the same slot;
|
||
point_physics_initialize_for_new_map is a confirmed false match).
|
||
The consume form's proven source is exemplified by point_in_triangle2d:
|
||
`vector_from_points2d(...)` immediately feeding `cross_product2d(...)` -
|
||
the non-popping fst bridges a value stored by one expression and consumed
|
||
by the IR-ADJACENT next expression.
|
||
|
||
**The axis's mechanical statement**: at a commutative float op with one
|
||
operand spilled/dying, VC7 emits the consume form exactly when the consumer
|
||
is IR-adjacent to the store; the accumulator orientation of the op follows
|
||
internal canonicalization order (source operand swaps are normalized away -
|
||
measured byte-inert at both sites). Whether the consumer IS adjacent is a
|
||
product of internal expression numbering that no measured source, flag,
|
||
header, PCH, or TU-level lever reaches. Both tie directions are reachable
|
||
by the compiler from ordinary source; which direction a given site takes is
|
||
decided below the source's visibility.
|
||
|
||
With flags, tuning, PCH, TU neighbours, header bodies, scopes, and local
|
||
spellings all excluded by measurement, the remaining instruments the
|
||
project's discipline would accept are: (a) instrumenting the compiler
|
||
binary itself (c2.dll) to expose its numbering, or (b) recovering a second
|
||
January occupant of the disputed cells to diff contexts. Neither is a
|
||
spelling sweep; both are new-instrument projects.
|
||
|
||
## Instrumenting C2.Dll: the compiler's own numbering is PGO-only and off-axis
|
||
|
||
Static reverse-engineering of the campaign backend (C2.Dll, VC7 13.00.9254,
|
||
imagebase 0x10700000; PE-map + string/xref tooling saved as
|
||
tools/c2_diag_map.py). The goal was to read the internal numbering that
|
||
decides the consume-vs-memory tie directly from the compiler.
|
||
|
||
**What the binary contains.** A full optimization-narration facility in
|
||
pass 2 (source path pogocg.c under P2): messages prefixed
|
||
OPT:/PEEP:/INL:/CNT:/INF:/WRN:, including the exact value-pair diagnostic I
|
||
was after - "PEEP: Comparisons reordered near line %lu (%I64u vs %I64u)" -
|
||
plus "OPT: %s Before:%I64d After:%I64d" and the inliner's
|
||
"INL: Inlining %s ... but can't: %s".
|
||
|
||
**Why it cannot narrate a January-matching build (three independent gates):**
|
||
1. Every message routes through logger 0x1082c892 -> sink 0x1082c8fb, which
|
||
reads global [0x1088c2c0] and returns immediately when it is null.
|
||
2. [0x1088c2c0] is the fopen handle set only by the opener 0x1082c790, which
|
||
runs only on the PGO/LTCG report path (mode global [0x1088c304]==2, alt
|
||
object name "ltcg.obj"). Per-TU /O2 never opens it.
|
||
3. Empirically: a /FAcs listing of ai_debug (the richest per-TU output the
|
||
backend offers) contains ZERO narration lines. Confirmed. Enabling the
|
||
facility requires LTCG/PGO, which changes the compilation model and voids
|
||
the exact match.
|
||
|
||
**And it is the wrong axis anyway.** Disassembling the one reorder peephole
|
||
it narrates (0x1083b5e0-0x1083b6ab): the two %I64u values are 64-bit block
|
||
execution frequencies (struct +0x28/+0x2c, propagated by addition into the
|
||
edi-block and subtraction from the esi-block). This peephole reorders
|
||
BRANCHES by PROFILE FREQUENCY - not the x87 operand-load / consume-vs-memory
|
||
instruction-selection decision that flips a910's fold and render_actor's
|
||
p0.z. The compiler surfaces no diagnostic for the x87-scheduling numbering
|
||
at all.
|
||
|
||
**Verdict.** C2.Dll exposes exactly one numbering - PGO block frequency -
|
||
and only under whole-program mode; it is both unavailable in the matching
|
||
build and orthogonal to our ties. The instrument that WOULD read the
|
||
relevant numbers is a live debugger breaking inside the x87
|
||
selection/scheduling pass (the code runs in every /O2 build; only its
|
||
diagnostic print is compiled behind the PGO gate). That is a separate,
|
||
larger undertaking; the facility map (tools/c2_diag_map.py) records the entry
|
||
points a future debugging session would attach to. No source lever this
|
||
session; unit unchanged at 56/60, 14,128 bytes.
|
||
|
||
## Live-debugging C2.Dll: observation works, intervention is WOW64-blocked
|
||
|
||
Built a Win32 debug host in Python (tools/c2_live_debug.py) driving the real
|
||
ai_debug compile, plus an EIP sampler (tools/c2_eip_sampler.py). Established:
|
||
|
||
- **C2.Dll loads in-process in CL.Exe at its fixed preferred base 0x10700000**
|
||
(no relocation, no child process, single WOW64 process). Every static VA from
|
||
the c2_diag_map analysis is a literal live address.
|
||
- **C2 executes** - the sampler catches EIP in c2's range in ~22% of samples,
|
||
hottest bucket 0x10701400 (a list-merge loop). So c2 does the codegen.
|
||
- **CL.Exe drives the backend through neither exported entry**: hardware/soft
|
||
breakpoints on _InvokeCompilerPass@12 (0x107b6938) and DllGetObjHandler never
|
||
fire; c2 is invoked another way (COM-ish object via DllGetObjHandler's vtable,
|
||
per the ObjectCodeGet/FCreateFromBytes strings).
|
||
|
||
**The wall.** From a 64-bit Python host, no execution-intervention primitive
|
||
works against the 32-bit WOW64 debuggee, each verified to "succeed" at the API
|
||
level yet have no effect on execution:
|
||
1. Software INT3 via WriteProcessMemory - plants (readback = 0xCC, persists to
|
||
process exit) but never traps.
|
||
2. Hardware BP via Wow64SetThreadContext - Dr0/Dr7 read back correct, never
|
||
fires (WOW64 shadow debug registers).
|
||
3. Hardware BP via the x64 SetThreadContext (physical DRs) - armed and read
|
||
back, never fires.
|
||
4. Guard-page via VirtualProtectEx (strip execute) - returns success, old prot
|
||
0x20 confirmed, no fault on execution.
|
||
The decisive control: guarding C1.Dll's .text (the front end, which MUST run)
|
||
produced ZERO faults either. So the guard/patch primitives themselves are not
|
||
enforced against this WOW64 target from an x64 debugger - a platform limitation,
|
||
not a logic error. Reads (ReadProcessMemory, Wow64GetThreadContext/Eip) all work
|
||
correctly, which is why observation and sampling succeed.
|
||
|
||
**The path that would work** (scoped, not built this session): a same-bitness
|
||
(32-bit) debugger. A 32-bit process debugging the 32-bit CL.Exe uses the native
|
||
x86 CONTEXT and normal INT3 - the WOW64 indirection that defeats items 1-4
|
||
disappears. No 32-bit Python is installed and no desktop C toolchain
|
||
(only VC7 itself + the SDK's x86 kernel32.lib at
|
||
Windows Kits/10/Lib/10.0.26100.0/um/x86), so the concrete build is a CRT-free
|
||
C debugger compiled with VC7 against kernel32.lib. Even with breakpoints
|
||
working, reading the tie's numbering then requires (a) locating the x87
|
||
selection/scheduling decision (no diagnostic string anchors it - see the
|
||
C2.Dll narration finding) and (b) decoding c2's IR node layout to interpret the
|
||
compared values. That is a multi-session RE effort; this session set up the
|
||
debugger, proved what it can and cannot do here, and recorded the entry points
|
||
(exports, hot addresses) and the exact next instrument.
|
||
|
||
Unit unchanged throughout at 56/60, 14,128 bytes (all work was out-of-process
|
||
against the compiler; source untouched).
|
||
|
||
## The 32-bit debugger: built with VC7, breakpoints work
|
||
|
||
Built the same-bitness debugger the previous section scoped (tools/c2dbg32/):
|
||
a CRT-free 32-bit Windows debugger compiled with the campaign's own VC7 cl.exe,
|
||
linked against the desktop SDK x86 kernel32.lib (no CRT, custom /ENTRY, all
|
||
Win32 prototypes declared inline; VC7 is C89 so declarations lead each block).
|
||
|
||
**It works where the 64-bit host could not.** Software INT3 at the confirmed-
|
||
hot c2 address 0x10701400 traps correctly - 6 hits captured with full x86
|
||
register + stack dumps and single-step re-arm, then auto-disarm so the compile
|
||
finishes. The WOW64 cross-bitness barrier (INT3 planted-but-never-trapped, DRs
|
||
armed-but-never-fired, guard-page unenforced) is gone once debugger and
|
||
debuggee are the same bitness; native GetThreadContext returns the real x86
|
||
context and WriteProcessMemory INT3 is honoured.
|
||
|
||
Capabilities now in hand: breakpoint any c2 VA during the real ai_debug
|
||
compile (c2 at fixed base 0x10700000, static VAs literal), read registers and
|
||
arbitrary process memory at each hit, up to 8 breakpoints, per-bp hit cap.
|
||
Build/run recipe and rationale in tools/c2dbg32/README.md; config generator
|
||
tools/c2dbg32/gen_config.py.
|
||
|
||
This completes the instrument. It does NOT by itself read the tie's numbering:
|
||
that still needs (a) locating the x87 selection/scheduling decision - which has
|
||
no diagnostic-string anchor (the OPT/PEEP narration is PGO-gated and keys on
|
||
block frequency, the wrong axis) - and (b) decoding c2's IR node layout to
|
||
interpret the compared values. The route in is now open: EIP sampling
|
||
(c2_eip_sampler.py) to narrow the codegen region during render_actor's
|
||
compilation, then targeted breakpoints with dbg32.exe to read the decision
|
||
inputs. Unit unchanged at 56/60, 14,128 bytes throughout (all work
|
||
out-of-process; source untouched).
|
||
|
||
## Hunting the x87 decision with the sampler + dbg32: narrowed, not pinned
|
||
|
||
Used the working debugger and samplers (tools/c2dbg32/acc_sampler.py,
|
||
diffsample.py) against real and synthetic compiles. Concrete results:
|
||
|
||
- **Sampling cannot find the decision, and now we know why by measurement.**
|
||
20 accumulated ai_debug compiles (718 samples, 173 in c2) put ~83% of c2
|
||
execution in ONE region: the arena allocator at 0x10701000
|
||
(`push 0x1000; call malloc; [eax+4]=0xff8`, free-list merge loop at
|
||
0x10701400) and a typed-node list search at 0x1070952a. The
|
||
consume-vs-memory decision fires only ~a dozen times per compile (the whole
|
||
corpus has 95 non-popping fst sites), so it is statistically invisible.
|
||
Sampling finds the hot *allocator*, never the rare *decision*.
|
||
|
||
- **A differential (FP-heavy vs int-heavy compile) isolates FP-codegen
|
||
functions** but is probe-sensitive. An array-indexed FP probe yielded a
|
||
shortlist (0x1070ecb8, 0x10746285, 0x1076f7ec, 0x10772706, ...) that, when
|
||
breakpointed during the REAL ai_debug compile with dbg32, DID NOT FIRE -
|
||
array-loop FP codegen exercises different paths than ai_debug's scalar
|
||
point/vector math. A scalar-local probe matching ai_debug's idiom compiled
|
||
too fast to sample densely. Lesson recorded: the differential probe must
|
||
reproduce ai_debug's exact FP-store idiom AND be slow enough to sample.
|
||
|
||
- **One FP-biased pass characterized**: 0x1070ecb8 (348 instrs) walks an IR
|
||
node graph via next-pointers, keyed on a node-KIND byte at [node+8]
|
||
(kinds 7 and 9) with a category test `word[node+0xa] & 0xf000 == 0x4000`.
|
||
The +8 byte is a node kind, not stack depth. This is FP-adjacent graph
|
||
machinery, but mapping it to the fst/fstp pop decision needs c2's IR node
|
||
layout (the kind enum, which kind is an FP store, and the pop-flag field).
|
||
|
||
**Honest terminus.** dbg32 works and can read any c2 state on demand; the
|
||
blocker is no longer tooling but KNOWLEDGE of c2's IR representation. The
|
||
remaining path is IR-layout reverse-engineering: breakpoint a per-function
|
||
codegen entry (fires ~60x for ai_debug's 60 sections), dump the IR node
|
||
structures for an FP store, identify the kind enum and the field that carries
|
||
"pop", then read that field at render_actor's p0.z store to see why our build
|
||
sets pop where January does not. That is a standalone RE project; this turn
|
||
built the instruments (sampler, differential, debugger) and narrowed the
|
||
target to the FP-codegen graph passes, but did not pin the exact branch.
|
||
Sampling is now retired for this purpose - it measures allocator heat, not
|
||
the decision. Unit unchanged 56/60, 14,128 bytes.
|
||
|
||
## Decoding c2's IR node layout live with dbg32
|
||
|
||
Enhanced dbg32 to dump the memory each GP register points at, then breakpointed
|
||
node-processing sites that fire during the REAL ai_debug compile (0x1070952a,
|
||
the kind-search that was ai_debug's 2nd-hottest c2 function). Read the IR
|
||
structure directly out of a running compile. Measured facts (full table in
|
||
tools/c2dbg32/IR_LAYOUT.md):
|
||
|
||
- **IR nodes are 0x60 bytes.** The `{link, nodeptr}` work-list arrays point at
|
||
nodes exactly 0x60 apart (0x02535e94 / +0x60 / +0x60 ...).
|
||
- **Kind byte at node+4.** The codegen driver 0x10735135 dispatches on
|
||
`mov al,[node+4]` with `cmp al,3/4/5/6 je` (a grouped enum); the list search
|
||
0x1070952a seeks kind 0x0C. ai_debug nodes carry kind 0x04.
|
||
- **Reciprocal operand links** at +0x08/+0x0c: two live nodes pointed at each
|
||
other (A+0xc -> B, B+0x8 -> A). Flags word at +0x10 (0x4004/0x1004/0x0604).
|
||
- **Two node types coexist**: ai_debug's codegen nodes use kind at +4; the
|
||
0x1070ecb8 pass (which does not run for ai_debug) uses kind at +8 with the
|
||
0xf000/0x4000 category word at +0xa - explaining the earlier mismatch.
|
||
- **Codegen call chain** recovered from live stacks: 0x10709395 ->
|
||
0x10735135 (per-node, dispatches on kind; calls interference test 0x1070943b
|
||
and the kind-0xC search) -> 0x1070952a.
|
||
|
||
**Honest state.** This is real IR-layout recovery - node size, the kind field,
|
||
a partial kind enum, the link/flag fields, and the codegen call chain, all read
|
||
from a live compile with the debugger built this session. Not yet mapped: which
|
||
kind value is an FP store, and which field is the pop flag. The next step is
|
||
mechanical with the tools in hand: breakpoint the x87 emit dispatch (a
|
||
kind-switch producing D8/D9 opcodes; start from the 0x10735135/0x10709395
|
||
chain), dump an FP-store node, then diff a known-fst node against a known-fstp
|
||
node - the differing field is pop - and read it at render_actor's p0.z. Each
|
||
turn has converted "we cannot see inside the compiler" into concrete, measured
|
||
structure; this turn decoded the node record. Unit unchanged 56/60, 14,128 bytes.
|
||
|
||
## Mapping the FP-store kind and pop flag: structure mapped, pop bit dynamic
|
||
|
||
Live probing with dbg32 plus static table analysis. Results:
|
||
|
||
- **Reproduced the non-popping fst in a tiny probe**: the centroid-average
|
||
(loop-accumulate then divide) emits `fst [ebp-4]` next to `fstp` - a910's
|
||
exact fold shape. Store-once and store+reuse-without-loop probes emit only
|
||
fstp (the "third form"); the loop-then-divide pressure is what forces
|
||
spill-and-keep. Now there is a fast fst/fstp reproduction outside ai_debug.
|
||
|
||
- **Kind byte confirmed at node+4** (dumped kinds 5 and 7 during the probe; the
|
||
kind-7 node had bit 31 set in the +4 word - a flag). Flags word at +0x10.
|
||
|
||
- **Located the x87 opcode table at 0x10852866 (.rdata)**: 6-byte records
|
||
{opcode, reg/subop, ...} - fadd d8/0, fmul d8/1, fcom d8/2, fcomp d8/3,
|
||
fdiv d8/6, fld d9/0, fld64 dd/0, plus the register-form p-variants.
|
||
**Crucially, no d9/2, d9/3, dd/2, dd/3 rows exist** - fst/fstp are NOT
|
||
separate table entries. A memory store shares fld's base opcode (d9/dd) and
|
||
the encoder computes the modrm reg field (0=fld, 2=fst, 3=fstp) at emit time
|
||
from the node. So the pop choice is a DYNAMIC node field, not a static
|
||
table row - which is exactly why no source spelling and no flag reaches it,
|
||
and why the tie is decided below source visibility (consistent with the
|
||
earlier corpus inversion finding).
|
||
|
||
**Honest state.** Mapped: the fst reproduction, the kind field, the opcode
|
||
table, and the fact that fst-vs-fstp is a dynamic reg-field computed from a
|
||
node bit. NOT yet pinned: the exact node bit. The remaining step is scoped:
|
||
find the store encoder's modrm formation (a d9/dd store that ORs reg 2 vs 3;
|
||
anchor the search on the opcode table 0x10852866 and the codegen chain), then
|
||
diff the node for an emitted fst vs fstp within the probe - the differing bit
|
||
is pop - and read it at render_actor p0.z. Unit unchanged 56/60, 14,128 bytes.
|
||
|
||
## Store encoder located; pop bit narrowed to a node field
|
||
|
||
Continued the dbg32 drill from the opcode table into the emitter.
|
||
|
||
- **Store/instruction encoder found**: low-level byte emitter 0x107455e6; the
|
||
code-emit cursor is the global [0x1088b788]. FP memory ops dispatch through a
|
||
jump table at 0x10745628 keyed on node[+4] = opcode enum (range 0x249-0x25a).
|
||
- **Modrm-formation at 0x10744304-0x10744318** builds the modrm byte and pulls
|
||
**bit 1 of node[+9]** as a control input to the emit call - the candidate
|
||
reg-field/pop selector.
|
||
- Live-captured q4 (has fst) and p1 (fstp only) FP-emission streams:
|
||
node[+4]=opcode, node[+8]=flags word (low nibble tracks the op).
|
||
|
||
Honest state: encoder and emit cursor are pinned; the pop selector is narrowed
|
||
to node[+9] bit1 / node[+8] low-nibble but NOT cleanly isolated, because the
|
||
emit path runs over multiple passes and node types. The finish is a clean
|
||
single-fstp vs single-fst node diff at 0x10744310, then reading that bit at
|
||
render_actor's p0.z. Unit unchanged 56/60, 14,128 bytes.
|
||
|
||
## RESOLVED: the fst/fstp tie is a computed encoder reg bit, not a source lever
|
||
|
||
Definitive, read live with dbg32 (full 0x60 node dumps correlated to emitted
|
||
bytes via the emit cursor [0x1088b788]):
|
||
|
||
- **FP memory store = opcode 0x258** (node[+4]) - verified by node->emitted-byte
|
||
correlation: 0x258 emits D9 55 (fst, reg=2) and D9 5D (fstp, reg=3).
|
||
- **No pop bit in the store node.** node[+4]=0x258 and node[+8]=0x04 are
|
||
byte-identical for fst and fstp; the only differing fields are operand
|
||
references to the stored value's producer. The pop (reg 2 vs 3) is COMPUTED
|
||
at encode time from whether that operand is still live - not stored.
|
||
|
||
So the consume-vs-memory choice at render_actor's p0.z and a910's fold is a
|
||
scheduler-liveness decision realized as a dynamic modrm reg bit, on a single
|
||
shared store opcode, with no field in the IR that any source spelling, flag, or
|
||
header could set. This is the compiler-internal confirmation of the earlier
|
||
corpus inversion: the tie is below source visibility by construction. Grounds,
|
||
if the owner agrees, to adjudicate render_actor and a910 as genuine
|
||
scheduler-internal ties (like the other measured non-source residuals) rather
|
||
than keep hunting a source that the mechanism proves cannot exist.
|
||
Unit unchanged 56/60, 14,128 bytes; all work out-of-process.
|
||
|
||
## Adjudication: render_actor and a910 as scheduler-internal ties
|
||
|
||
Owner-directed adjudication, grounded on the compiler-internal proof (the
|
||
live-debug thread above: FP store = opcode 0x258; fst-vs-fstp is a modrm reg
|
||
bit COMPUTED from operand liveness at encode time, carried by no field in the
|
||
IR store node - so no source/flag/header/node edit can steer it).
|
||
|
||
Both functions stay NonMatching and are NEVER credited; the unit remains
|
||
honestly 56/60, 14,128 bytes. Adjudication = tracked, reopenable acceptance,
|
||
not a byte-match and not a silent close.
|
||
|
||
- **ai_debug_render_actor -> config/parked.json** (class instruction-scheduling).
|
||
Mechanically validated: `python -m tools.parked_functions` reports it active
|
||
(target 24976 / base 24992, reloc 1905/1904, objdiff 95.81%). The manifest
|
||
auto-reopens the function if the measurements drift or it ever becomes
|
||
semantically exact, so this is debt with an automatic reopen, consistent with
|
||
"everything must eventually byte match".
|
||
|
||
- **code_0003a910 -> adjudicated here, NOT in config/parked.json.** The parked
|
||
manifest validator is symbol-based (`section_info(base, "_code_0003a910")`),
|
||
but the rebuilt object emits a910 (and 39990) as a static function WITHOUT a
|
||
name-matchable symbol - only render_actor among ai_debug's residuals gets one.
|
||
Giving a910 a symbol would require making it non-static, which changes its
|
||
codegen (an illegitimate tooling-driven source edit), so it stays static and
|
||
out of the manifest. Its residual is the SAME liveness axis: the R4 z-fold
|
||
(keep-on-stack reciprocal vs fmul [mem]) is the consume-vs-memory tie proven
|
||
above, plus the pass-2 walker register (EAX vs EBX), a register-allocation
|
||
tie. Adjudicated a scheduler-internal tie by the same evidence.
|
||
|
||
Reopen criterion (both): a new instrument or worked example that exhibits a
|
||
source, flag, or IR-node lever which flips the encode-time liveness decision -
|
||
i.e. any counterexample to the "computed reg bit, no stored lever" proof. Until
|
||
then these are not source questions.
|
||
|
||
Note on the standing rule: "no parking as an end state" is honoured because
|
||
parked.json is a reopenable debt ledger (auto-reopen on drift/exactness), not a
|
||
terminal state; the acceptance is evidence-backed and reversible, and the score
|
||
is untouched.
|
||
|
||
## Adjudication: code_00039990 as a scheduler-internal tie
|
||
|
||
Owner-directed, same disposition as a910. code_00039990 is 848/848 bytes with a
|
||
single 3-byte non-relocation residual: one transposition at 0x229 - January
|
||
emits `fld st(0) / fmul [eax] / push edx`, the rebuild emits the `push edx`
|
||
before the `fmul`. This is an instruction-SCHEDULING decision: the interleave
|
||
order of a call-argument push against x87 work at the entry-point idiom.
|
||
|
||
Evidence it is a genuine scheduler tie, not a source question:
|
||
- nine legal-C source forms measured and rejected (nested call, shared/scoped
|
||
v x3, member-wise copy, commuted sum, point-via-copy) - all reproduce the
|
||
same transposition;
|
||
- the shape is corpus-unique (emission_shape_oracle returns zero exact-function
|
||
examples at every window width - no worked source exists to copy);
|
||
- same R4/instruction-scheduling family as a910's z-fold and render_actor's
|
||
p0.z store-pop tie (the latter proven by live c2 debugging to be an
|
||
encode-time decision with no IR-node/source lever). Honest scope: the c2
|
||
live-debug proof directly covers the store-pop reg bit, not this specific
|
||
push/fmul interleave; 39990's adjudication rests on the exhaustive sweep +
|
||
corpus-uniqueness + same class, not a byte-level proof of this transposition.
|
||
|
||
Like a910, code_00039990 is a static function the rebuilt object (build/base)
|
||
emits with NO name-matchable symbol, so it cannot enter config/parked.json
|
||
(the validator is symbol-based); making it non-static to get a symbol would
|
||
change codegen and is off-limits. Adjudicated here instead. Stays NonMatching,
|
||
never credited; unit remains honestly 56/60, 14,128 bytes.
|
||
|
||
Reopen criterion: a source, flag, or IR-node lever that flips the push/fmul
|
||
schedule - i.e. any counterexample to the nine-form sweep or a corpus worked
|
||
example of the shape. Until then it is not a source question.
|
||
|
||
## Adjudication: code_00041220 as a tu-context inliner tie
|
||
|
||
Owner-directed (offered "close or adjudicate"; a fresh close attempt failed).
|
||
41220 is 2336 (target) / 2544 (rebuild), +208 bytes: ai_debug_drawstack() is
|
||
inlined at two sites the January build calls (target 3 calls + 1 inline; rebuild
|
||
1 call + 3 inlines; instruction mix +6 each fld/fmul/fadd/fstp, -2 call).
|
||
Relocation identities are correct; the count differs (169 vs 201) only by the
|
||
inlined bodies' global references.
|
||
|
||
Close attempt this session: bound the drawstack result to a named local at the
|
||
surplus site - inert (call count unchanged 1/3). Prior measured negatives:
|
||
result casts, parenthesized callee, caller-side mass, do-while(0), csprintf
|
||
hoisting, callee-body mass (overshoots to all-calls). The unit's four sites are
|
||
already written uniformly. Precedent code_00039e80 shows the inliner reproduces
|
||
January's mixed call/inline split faithfully from uniform source - so the split
|
||
is decided by the inliner's per-function/TU-context cost, not local spelling.
|
||
|
||
Adjudicated a tu-context-optimization tie and PARKED in config/parked.json
|
||
(class tu-context-optimization), validated active by tools.parked_functions
|
||
(auto-reopens on drift/exactness). Unlike a910/39990 it has a name-matchable
|
||
base symbol, so the mechanical manifest holds it. Evidence basis: exhaustive
|
||
form-sweep + inliner-context precedent - the same standard as the existing
|
||
register-allocation parks, and honestly weaker than the store-pop
|
||
compiler-internal proof (this is not proven unreachable at the c2 level, only
|
||
unreached by every measured legal-C form). Stays NonMatching, never credited;
|
||
unit remains honestly 56/60, 14,128 bytes.
|
||
|
||
Reopen criterion: any source form, flag, or measured inliner-cost lever that
|
||
flips the two surplus sites to calls without perturbing the correctly-split site.
|
||
|
||
## RULE CHANGE 2026-08-10: adjudication withdrawn, all four residuals are OPEN
|
||
|
||
Owner ruling: adjudication is no longer acceptable - everything must byte match
|
||
or it does not count. The dispositions recorded earlier in this log for
|
||
ai_debug_render_actor, code_0003a910, code_00039990 and code_00041220 are
|
||
WITHDRAWN. All four are open work items again, not accepted ties.
|
||
|
||
Concretely:
|
||
- The two entries this campaign added to config/parked.json
|
||
(_ai_debug_render_actor, _code_00041220) have been removed. The eight
|
||
pre-existing entries from other objects were left untouched - they are not
|
||
this campaign's to retire.
|
||
- The log-only adjudications of code_0003a910 and code_00039990 are likewise
|
||
void.
|
||
|
||
What does NOT change: no score was ever affected. This object has been reported
|
||
at an honest 56/60, 14,128 bytes throughout, because parked and adjudicated
|
||
functions were never credited. The ledger needs no correction - only the
|
||
disposition language does.
|
||
|
||
What the evidence still means: the c2.dll live-debug result (FP store is opcode
|
||
0x258; the fst-vs-fstp reg bit is computed at encode time from operand liveness
|
||
and is carried by no field in the IR node) remains valid as MECHANISM. It no
|
||
longer licenses closing a function. Its correct standing now is: it tells us
|
||
which source-level searches are futile, and it says that closing these four
|
||
requires either changing the surrounding IR state so the liveness computation
|
||
comes out differently, or finding an input to that computation nobody has
|
||
identified yet. That is a harder target than "find a spelling", and it is now
|
||
the required target.
|
||
|
||
## 39990 fresh probe under the no-adjudication rule (2026-08-10)
|
||
|
||
The 3-byte residual is the push/fmul transposition at 0x229 (January fmul [eax]
|
||
then push edx; ours push then fmul), where push edx is the colour argument of
|
||
the attractor block's render_debug_string_at_point and the fmul begins the
|
||
inlined point_from_line3d at ai_debug.c line 545. Applying the 39e80 lever
|
||
(expand that point_from_line3d call longhand to renumber the inline IR)
|
||
REGRESSED it 3->7, so the inline form is closer and the spelling is not the
|
||
lever. The colour is loaded once (mov edx,[ebp+0x2c]) well before either
|
||
position; only the scheduler chooses the push slot. Same encode-time class as
|
||
the other three; no source lever found across the prior nine forms plus this.
|
||
Remains OPEN (byte-match required); the live-scheduler instrument is the path.
|
||
## 2026-08-11 re-attack under the byte-match-only rule (all four remain OPEN)
|
||
|
||
Fresh measurements, all scored whole-unit with section_infos_equal (56/60
|
||
throughout; nothing regressed, nothing credited):
|
||
|
||
**render_actor - the p0/p1 statement-order space is now fully measured.**
|
||
The marker-arm pair (source ~1240-1245) was probed in five orders plus a
|
||
named-base variant. Baseline (p0.x,y,z then p1.x,y,z) is the ONLY order that
|
||
reproduces January's spill structure at the p0.z site (prod.k spilled at
|
||
-0x1c, prod.i/j kept stacked for p1.x/y's fsub st(i) consumption); every
|
||
other order loses the spill entirely (24,960, 6,858 instrs) because adjacent
|
||
z-uses keep the product stacked. p1-first hits January's exact SIZE (24,976)
|
||
but is structurally far worse (4,288 slot-blind instruction diffs vs
|
||
baseline's alignment) - the size gate misleads here exactly as the census
|
||
warning says. Conclusion sharpened: the baseline order is source-correct and
|
||
the residual truly is the single fst-vs-fstp bit at 0x1046 plus downstream
|
||
allocation state; order levers over-rotate and cannot approach it.
|
||
|
||
**39990 - two new negatives.** Compound-assignment spelling
|
||
(position.x += up->i*height, all three components, and first-trio-only) is
|
||
front-end canonicalized: byte-identical output to baseline. The single
|
||
push-vs-fmul transposition at 0x229 stands; the push (weight_color, batched
|
||
early for the second render_debug_string_at_point) slides one scheduler slot
|
||
into the height trio's FP chain.
|
||
|
||
**41220 - NEW upstream finding: the first divergence is NOT the inline
|
||
split.** At 0x40, January materializes the history_start_time NONE as
|
||
`or edi,-1; mov [ebp-0x3c],edi` and REUSES edi for the in-loop
|
||
selected_actor_index==NONE compare (cmp ecx,edi at 0x75, edi callee-saved
|
||
across the iterator calls); ours emits two independent literals
|
||
(mov dword [ebp-0x3c],-1 and cmp ecx,-1). Coupled fork: January binds the
|
||
TAG_BLOCK_GET_ELEMENT definition result to EBX, ours to EDI - ours' edi
|
||
being taken is why our -1 gets no register. Assignment-position probes
|
||
(init-at-decl, before definition=, after iterator_new) move the store's
|
||
scheduled slot but never produce the register CSE. The +208 inline split
|
||
sits DOWNSTREAM of this allocation fork. Reopen lever: whatever steers
|
||
definition into EBX (frees edi for the constant) - the same
|
||
allocation-stage family as sound_class_get/unlock_handle, "reachable in
|
||
principle, lever not found."
|
||
|
||
a910 not re-probed this pass (its forty-probe fold history already covers
|
||
the fresh angles tried elsewhere today).
|
||
|
||
## 2026-08-13 fresh-canonical salvage and independent verification
|
||
|
||
This checkpoint was rebuilt from canonical commit
|
||
`a0bad121b7b4e37335acb3ab9a7825fb99861a7a`. The divergent
|
||
`claude/libcmt-stream` branch was evidence only: its `ai_debug.c`, typed headers,
|
||
and this log were copied into a new isolated worktree, then compiled and scored
|
||
again with the campaign's XDK 3911 compiler and the configured
|
||
`/O2 /Oy- /QIfist /DDEBUG /Dxbox` flags. No commit from that branch was merged.
|
||
|
||
The clean canonical baseline contained 11/60 strict functions. The minimized
|
||
transplant independently reproduces 56/60 strict functions and 14,128 padded
|
||
bytes. Every credited function passes `section_infos_equal`, including the
|
||
relocation identities and addends. The four open residuals are:
|
||
|
||
| function | January / candidate bytes | January / candidate relocs | relocation identities | candidate SHA-256 |
|
||
|---|---:|---:|---|---|
|
||
| `_code_00039990` | 848 / 848 | 42 / 42 | equal | `77336ed2facf6f0eabe826e0b59f5ae2f69a0e586583af2ed82553ec590fd430` |
|
||
| `_code_0003a910` | 704 / 704 | 23 / 23 | not equal downstream of the code drift | `1f99ed4877b1ec2454721d3b5c80c353113acf40278864f7b8e2794193d3e2f9` |
|
||
| `_ai_debug_render_actor` | 24,976 / 24,992 | 1,905 / 1,904 | not equal | `f7082fce9acf92b868be0c6bf50cc7c9b58f5079722e950db537a2153da646c6` |
|
||
| `_code_00041220` | 2,336 / 2,544 | 169 / 201 | not equal after the surplus inlines | `f9220b2efb233322e1a1536ffee0b60af13fc8d05e4e465b25b2eab065fbc4e9` |
|
||
|
||
The object remains `NonMatching`. Nothing in this checkpoint grants credit to
|
||
those four functions or to the object as a whole.
|
||
|
||
### Header minimization and blast-radius proof
|
||
|
||
Only definitions actually consumed by the reconstructed translation unit were
|
||
retained: actor-looking validity, AI-global/debug layouts, AI communication and
|
||
profile interfaces, encounter/path iterators and layouts, local-player and unit
|
||
speech declarations, the rasterizer frame bounds, the encounter starting
|
||
location, and the unit dialogue variant. Claude's unrelated `real_math.h`
|
||
changes and all config/symbol changes were deliberately omitted.
|
||
|
||
Three shared-definition repairs were needed to keep the recovered layout
|
||
readable and source-compatible outside this unit:
|
||
|
||
- `rasterizer_globals.reserved04` remains one aggregate initializer field; a
|
||
nested layout names `frame_bounds` at +0x0C without breaking `rasterizer.c`.
|
||
- the encounter-only starting-location record has its own descriptive type name,
|
||
avoiding a collision with game-engine source.
|
||
- `ai_profile_globals` now has one header-owned definition, and `ai_profile.c`
|
||
uses that definition rather than carrying a conflicting private copy.
|
||
|
||
`ninja halobetacache_build semantic_progress` completed after those repairs.
|
||
The fresh report spans 470 units. Against a report generated from the clean
|
||
canonical baseline, accepted-ledger functions changed by **+45 / -0** and
|
||
semantic-exact functions changed by **+45 / -0**; ordinary rejections changed by
|
||
zero. Thus the shared headers caused no accepted-function regression anywhere
|
||
in the build. The 45 newly exact entries are all in `ai_debug.obj`; the apparent
|
||
11-to-56 increase is exactly this set.
|
||
|
||
### New bounded `_code_00041220` result
|
||
|
||
The first divergence was traced to a real source defect, not the downstream
|
||
inliner split. January materializes `NONE` in EDI and reuses it for the history
|
||
time, selected-actor test, and firing-position-color sentinel. The recovered C
|
||
tested `global_ai_debug_firing_position_color_count == 0`; the January operation
|
||
is `== NONE`. Correcting the predicate makes VC7 keep `NONE` in EDI and assign
|
||
the tag-block definition to EBX, reproducing the January register topology.
|
||
|
||
A genuinely new lifetime probe named a separate `long no_actor = NONE` carrier.
|
||
It was a byte-identical no-op and was restored. Removing the redundant named
|
||
`firing_positions` tag-block alias, while continuing to use the typed
|
||
`definition->firing_positions` member directly, then made the candidate prefix
|
||
instruction-identical through +0x141. The first differing branch displacement
|
||
there is downstream of the still-present 208-byte surplus: two call sites inline
|
||
`ai_debug_drawstack` that January calls out of line. None of the previously
|
||
logged casts, parentheses, caller/callee mass, do/while, or `csprintf` shapes was
|
||
repeated.
|
||
|
||
This is meaningful progress but not completion. The reopen question is now
|
||
narrow: find a natural source/context lifetime that changes the two surplus
|
||
inlining decisions without perturbing the now-correct EDI/EBX prefix or the one
|
||
site whose mixed inline/call decision already matches January.
|
||
|
||
### Checkpoint gates
|
||
|
||
- A forced delete and rebuild of `build/base/source/ai/ai_debug.obj`, followed by
|
||
`halobetacache_build`, `libcmt_build`, and `semantic_progress`, reproduced
|
||
56/60 and 14,128 padded strict bytes with the same four residual hashes above.
|
||
- The whole-project semantic report contains 470 units, 3,481 semantic-exact
|
||
functions, and 3,565 accepted exact functions, with zero unit errors.
|
||
- `tools.audit_object_admission` reports no admission candidate for AI debug.
|
||
This is the expected fail-closed result: the source remains `NonMatching` and
|
||
the four residuals remain open.
|
||
- `python -m unittest discover -s tools -p 'test_*.py'` passes all 179 tests.
|
||
- `git diff --check` passes. The changed source contains no inline assembly,
|
||
`volatile` addition, force-inline directive, optimization pragma, barrier,
|
||
flag change, type-punning codegen cast, comparator exception, or byte patch.
|
||
- House/Berth review: recovered structures have named semantic members only
|
||
where target accesses prove them, unidentified spans stay explicitly unknown,
|
||
target assert/string identities remain load-bearing, and the sentinel defect
|
||
is expressed as ordinary readable C rather than a code-generation trick.
|
||
|
||
The regression gate's clean-canonical manifest correctly cannot be used as a
|
||
zero-diff approval for this checkpoint: a deliberate reconstruction changes the
|
||
translation unit's section and symbol inventory and adds 45 strict functions.
|
||
That comparison reported the additions rather than hiding them. A clean
|
||
checkpoint snapshot followed by a forced rebuild/check is the reproducibility
|
||
gate for the retained source; its outcome is recorded in the handoff together
|
||
with this ledger.
|
||
|
||
## Current-canonical integration and strict admission (2026-08-29)
|
||
|
||
The checkpoint above was transplanted onto canonical commit `71d1693e` only
|
||
after AI debug was released from Claude's active lane. The old worktree's two
|
||
uncommitted experimental files were treated as read-only evidence and were not
|
||
included. The independently audited `punpckhdq/halo` donor remains a verified
|
||
no-op for this object: it has no strict exact AI-debug function absent from
|
||
canonical.
|
||
|
||
Current canonical already held 23/60 strict functions and 3,120 padded exact
|
||
bytes. The integrated candidate holds 56/60 and 14,128 bytes, for a net gain of
|
||
33 functions and 11,008 bytes with no loss from the canonical exact set. A
|
||
whole-build comparison covered 568 comparable objects and found zero lost exact
|
||
functions anywhere; AI debug was the only object with gains.
|
||
|
||
The integration also removed representation casts and a raw path-step address
|
||
expression from the uncredited residual source. Typed point/vector operations,
|
||
subsystem access macros, locally scoped external-layout views, and named debug
|
||
flags preserve the exact 56-function set without relying on code-generation
|
||
tricks. Every one of the 60 function bodies ends with an explicit `return`, and
|
||
the recovered declarations use the current one-parameter-per-line style.
|
||
|
||
The four residuals remain honest zero-credit work:
|
||
|
||
| Function | January / candidate bytes | January / candidate relocs | Candidate normalized SHA-256 |
|
||
| --- | ---: | ---: | --- |
|
||
| `_ai_debug_render_actor` | 24,976 / 24,848 | 1,905 / 1,903 | `e20fcee6e777e36b36fa9e0fa4acfc8ddc46995707cd61aceffda34f092ec2c4` |
|
||
| `_code_00039990` | 848 / 848 | 42 / 42 | `77336ed2facf6f0eabe826e0b59f5ae2f69a0e586583af2ed82553ec590fd430` |
|
||
| `_code_0003a910` | 704 / 704 | 23 / 23 | `1f99ed4877b1ec2454721d3b5c80c353113acf40278864f7b8e2794193d3e2f9` |
|
||
| `_code_00041220` | 2,336 / 2,544 | 169 / 201 | `f9220b2efb233322e1a1536ffee0b60af13fc8d05e4e465b25b2eab065fbc4e9` |
|
||
|
||
Current gates:
|
||
|
||
- `halobetacache_build`, `libcmt_build`, and `semantic_progress`: pass;
|
||
- semantic report: 470 units, 4,715 semantic exact, 4,725 accepted exact,
|
||
zero unit errors, and zero ordinary-C rejections;
|
||
- AI-debug gate: 56 exact, 4 residual, 0 unwritten;
|
||
- pre/post exact-set comparison: +33 / -0 functions, +11,008 bytes;
|
||
- 568-object whole-build exact-set comparison: no lost exact functions;
|
||
- `python -m unittest discover -s tools -p 'test_*.py'`: 205 tests pass;
|
||
- object admission audit: no candidate, contradiction, or revocation, which is
|
||
the expected fail-closed result while four functions remain residual;
|
||
- no assembly, new `volatile`, `register`, optimization pragma, intrinsic,
|
||
barrier, attribute, byte forcing, raw address access, object patch, tool
|
||
exception, comparator exception, or compiler-flag change.
|
||
|
||
## Exhaustive later-tree census and `_code_00041220` minimization (2026-08-29)
|
||
|
||
An exhaustive read-only census checked 1,487 registered worktrees, 1,077 built
|
||
`build/base/source/ai/ai_debug.obj` artifacts, and 1,056 distinct object hashes.
|
||
A second filesystem scan covered `C:\halo-worktrees`, `C:\tmp`, and the complete
|
||
Codex workspace; its six unregistered build artifacts were all older 11/60
|
||
objects. The only artifact strictly ahead of current canonical was:
|
||
|
||
`C:\Users\isabe\Documents\Codex\2026-07-13\i-w\work\ai-debug-salvage-20260813\build\base\source\ai\ai_debug.obj`
|
||
|
||
Its raw object SHA-256 is
|
||
`f1cabaaba68b527cd93884ce3de293e0f48bbf854aa91cd6b35b25261f74792e`.
|
||
It measures 57/60 strict functions and 16,464 padded exact bytes, adding only
|
||
`_code_00041220` (2,336 bytes) and losing none of canonical's 56 exact owners.
|
||
Transplanting that function's dirty source into the clean canonical integration
|
||
tree reproduced the same 57/60 result exactly.
|
||
|
||
The 57th match is not admissible under the house rules. It depends on a one-use
|
||
local function-pointer alias of `ai_debug_drawstack`, followed immediately by a
|
||
call through that alias. Replacing only that alias with the direct typed call
|
||
returns the object to 56/60. The alias has no semantic selection or callback
|
||
purpose and fails the hard-prohibition test: it would not plausibly exist in the
|
||
2001 source for a reason other than suppressing one inline decision. It is
|
||
therefore recorded as byte-forcing evidence, not credited or retained.
|
||
|
||
The remaining source changes are ordinary typed C and were minimized separately.
|
||
They recover target-backed branch structure, color-pointer indirection, local
|
||
lifetimes, assignment order, and the two-arm pursuit/evaluation rendering
|
||
topology. With the prohibited alias removed, they preserve the complete 56-owner
|
||
exact set and improve the open `_code_00041220` residual as follows:
|
||
|
||
| measurement | previous canonical | retained ordinary-C subset |
|
||
|---|---:|---:|
|
||
| candidate bytes | 2,544 | 2,448 |
|
||
| candidate relocations | 201 | 185 |
|
||
| target bytes / relocations | 2,336 / 169 | 2,336 / 169 |
|
||
| candidate normalized SHA-256 | `f9220b2efb233322e1a1536ffee0b60af13fc8d05e4e465b25b2eab065fbc4e9` | `46fab4ca08429eb923a46d573915c2419659690a1c74226612503413cd647f59` |
|
||
|
||
This is a 112-byte and 16-relocation reduction with no strict regression, but it
|
||
earns zero new credit. The remaining difference is exactly one surplus inlined
|
||
`ai_debug_drawstack` body. A naturally justified source/context lifetime must
|
||
make that site a direct call; a function-pointer alias, invented wrapper, forced
|
||
inline/noinline control, or other optimization-only construct is not an
|
||
acceptable closeout.
|