256 KiB
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]takefstpthen laterfadd-- three consecutive floats, i.e. areal_point3dorreal_vector3dbased at-0x100.[ebp-0xf0]is bothfstp/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 literal2at one point.
January also packs sub-dword locals more tightly than we do: it has a byte at
0x31 and 0x75 and words 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
- Recover the five missing locals in
ai_debug_render_actorand getsub espto0x810. Highest leverage in the object by a wide margin. - Re-measure before anything else; the relative-displacement noise should drop sharply and reveal the real remaining differences.
- 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:
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:
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:
- The four negative-constant sites in
render_actor(see above). Twelve surplus constants and one missing string literal stand between it and exact. - 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:
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_vectors3dthe size lands exactly. p2reused as the scratch, not a new local. A freshreal_vector3d scaledgives the same size and constants but pushes the frame to0x81c. Reusingp2-- which is overwritten by the subtraction on the last line anyway -- keepssub espon0x810. This is the "January reuses locals as scratch" rule fromvehicles.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
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:
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:
"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:
ai_communication_get_player_ratingdeclaredreal unit_indexfor an index. Worth 16 bytes and restored the exact section size honestly.- 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:fild2/2,fisub1/1,fistp0/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_F4branch at line 3007, and both builds copy the source struct twice rather than copyingp4intop3. The extra live slots are the enclosing block'sp0/p1, which January retires before the inner block so thatp3/p4reuse 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/0x71care January's0x310/0x710losing a vote, and0xc0is 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,
{ 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.
p1as threerealscalars -- frame 0x820, size +128. The scalars take a slot each plus alignment, costing 16 where the point cost 12.p1eliminated, relying on CSE ofbase_point + up*0.1-- frame 0x820, size +128, and three surplus0.1constants: 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:
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:
-
ai_debug.render_lineofsight(0xAA, already named) gates two stores that zerolineofsight_point_countandlineofsight_pair_count-- byte for byte whatai_debug_lineofsight_resetdoes. 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. -
Three new booleans at 0x3C, 0x3D and 0x3E, currently inside
__unknown3C[24]. 0x3C gates the entire rest of the function --jejumps past everything to 0x2d7. 0x3D and 0x3E each gate one of the blocks below. -
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 withobject_try_and_get_and_verify_type, callsbiped_find_pathfinding_surface_index, and on success fills:offset contents 0x4C7E8 booleanvalid, set last0x4C7EC real_point3d, the out-parameter atebp-0x1c0x4C7F8 longsurface index, the return value0x4C7FC longunit indexThe point is written from three dwords read out of
ebp-0x1c, sobiped_find_pathfinding_surface_index(unit_index, &point)returns the surface index and fills a point. -
When 0x3E is clear, it calls
observer_get_camera(0), and on a non-null result increments a counter inai_profileat 0xb34 and begins scalingglobal_down3dby__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.2fpath_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 reals 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:
- Write the statics bottom-up and
ai_debug_renderlast. Nothing is wasted and every function is verified as it lands, but no section scores until its callees exist. - Write
ai_debug_renderfirst 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-- ashortglobal, written fromrasterizer_globals->+0x10minus 0x14.- the
rasterizer_globalsinstance --rasterizer.hdefinesstruct rasterizer_globals_definitionbut 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 isactive, at 0x0d.[edi+0x2a]is printed as(%d actors)-- that iscurrent_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_blocks), 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:
struct encounter_actor_iterator
{
long encounter_index; // +0
long actor_index; // +4
long next_actor_index; // +8
};
newstores the index at +0, writesNONEto +4, then seeds +8 fromencounter_get(index)->first_actor_index-- offset0x14, which is exactly wherefirst_actor_indexsits in the tree'sstruct encounter_datum. When the index isNONEit seeds +8 fromai_globals+8instead.nextcopies +8 into +4, returnsNULLif that isNONE, otherwise returnsdatum_get(actor_data, index)and refreshes +8 from the actor's+0x2c.- Both
nextandprevreturn the actor pointer, not a boolean: the failure pathsxor eax,eaxand the success path leaves thedatum_getresult ineax.
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 inesiand used asmovsx edx, si/inc edx, soconsole_printf(..., actor_number+1, ...). Alongwould not sign-extend. - The search loop is a plain rotated
while: January callsnextonce before the loop head and again at the bottom, withinc esibetween theactor_indextest and the second call. That is whatwhile (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_globalsis a pointer to a0x8DCgame-state block (ai_initializemallocs exactly that).+0isai_active, named byai_globals_ai_activeinai.obj;+1is written only byai.cand cleared byai_dispose_from_old_mapafter everything is disposed, which is what names it.global_ai_debug_string_position = rasterizer_globals.frame_bounds.y1 - 20. The field atrasterizer_globals+0x0Cis arectangle2d, proved bymain.objcomputing[0x10]-[0x0c]and[0x12]-[0x0e]and using those to subdivide a screen --y1-y0andx1-x0on the tree's{y0,x0,y1,x1}layout. Themov ecx, dword ptr [...+0x10]for ashortfield is MSVC widening a 16-bit load whose result is truncated back to 16 bits anyway; it is not evidence of along.global_ai_debug_string_positionis an undefined external inai_debug.obj, as areai_globalsandrasterizer_globals, so only the declaration belongs here.ai_debug.selected_squad_index = actor_get(...)->meta.encounter_index. The actor field at+0x34needed no new name:struct actor_datumputsmetaat0x04(theshort identifieris followed by 2 bytes of padding becauseactor_meta_datais 4-aligned), andmeta.encounter_indexis at0x30within it. The same shift independently explains the iterators'actor+0x2casmeta.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_disabledthroughprint_uncovering, exactly 32 bytes. The dispatcher's third speech-related gate at0x93isprint_speech.- The gate at
0xA5that the earlier dispatch table missed is justai_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:
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 MINs, 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 ajmp, +16 bytes. - Assigned to a
longlocal 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_0003af00passes0x6092Ctoai_debug_drawstack_setupand0x60904toai_debug_render_path_storage-- and0x60904 + 0x28ispath_state.input.start_point, exactly the paircode_00041120uses on the same type.ai_debug_updatewrites0x60904 = NONE,0x60908 = game_time_get()and0x60910 = TRUE, which areactor_index,path_timeandvalidat +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_valid0x4C89C=+0x80=path_state.node_count, and the barecmp ax, 0x400incode_0003af00is>= PATH_NODE_LIST_SIZE, whichpath.hdefines as0x400.
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:
render_lineofsightzeroeslineofsight_point_countandlineofsight_pair_count-- the two storesai_debug_lineofsight_resetmakes, written out rather than called.ai_debug.pathgates the whole debug pathfinding query: fill the start record from the local player's biped unlesspath_start_freeze, fill the end record by casting a 1000-unitglobal_down3dray from the camera throughcollision_test_vectorunlesspath_end_freeze, then build apath_input, runpath_state_new/path_state_find/path_state_build_path, and stamp the debug storage withNONE,game_time_get()andvalid.- 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), thepath_input(0x48) and one 12-byte point slot across three disjoint scopes. Declaringinputat the top of theif (ai_debug.path)block keeps it live across the camera ray and costs 84 bytes of frame. Moving it intoif (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_4C814is areal, not along. January emits the 10-bytemov dword ptr [0x4c814], 0; ours emitted the 6-bytemov [0x4c814], edi, because MSVC knewediheld 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_indexout ofeaxfirst, which freeseaxfor the third point component and lets that store use the shortA3encoding. Writing the point first cost one byte. The end record is the same trick withpath_end_validassigned before the point. - The default-value ternary needs the default in the fallthrough. Written
x!=0.f ? x : defaultMSVC puts the value in the fallthrough and jumps to the default; January is the other way round.x==0.f ? default : xmatches. 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 thehorizontal_angleminimum 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:
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
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:
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:
+0xB6isobject.damage_flags, and bit 2 is_object_dead_bit+0x338isunit.speech, whose first member iscurrent.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, frame0x81Cagainst0x810.ai_debug_render_path_storage-- 512 against 816; needscode_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]-- thepath_traverse_resultandpath_build_resultnames, whose enumerationspath.halready declares. The twomatch_asserts 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 iscsprintf(temporary, "%s / %s (%d)", traverse_name, build_name, game_time_get()-path->path_time). [ebp-4]is abooleanholdingpath->structure_bsp_index == global_structure_bsp_index_get(), reused much later to gaterender_debug_path.
What it still needs, all inside the same 51 KB tail:
0x1458Aashortavoidance-segment count,0x1458Can array of stride 0xC08, and0x175ACan array of stride 0x1534, indexed byai_debug.render_paths_avoidance_segment(0xE0).- Prototypes for
render_debug_obstaclesandrender_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, and0x3B4is the nextai_globalsoffset used anywhere --ai.cwrites a byte there andactions.creads 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.
- Naming the pointer --
real_point3d *effect_point = &effect->point;-- produced January'sedi/esisplit immediately. - But it then emitted the
leatoo 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 theleato 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:
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:
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+0x2B4is atag_blockof0x18-byte elements.- It scans that block for the element whose
+0x14equals the unit's+0x334, and takes that element's leadingshort. - 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_drawstackis inlined here. The next/last position swap and theup * drawstack_heightadd appear as straight-line code at0x109-0x147, where every other function in this object emits acall. 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.speechis at0x338, so+0x334is the field immediately before it -- run the offset oracle on_unit_datumrather 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:
/* 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 invertedactor->meta.swarmguard).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, exactlyNUMBER_OF_SOLO_CAMPAIGN_TEAMSingame.h, names matching the enum member for member; - actor types -- 16 entries,
elite..mounted weapon, exactlyNUMBER_OF_ACTOR_TYPESinactor_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_globalsand 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:
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
esioutright; - the team and actor-type loops get
ebxandedifor 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]); &vehicleis 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
encountersafter the two counters (moves a zero store before the call — further from January, not closer); - declaring
encounterswithout an initialiser and assigning it as the first statement; - hoisting the inner
group_indexdeclaration 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:
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 fsts 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 —
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:
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:
if (ai_debug.render_dialogue_variants)(0x79) —tag_get('unit', unit->tag), then a linear search over the tag'stag_blockat+0x2b4(element size 0x18) for the element whose tag index at+0x14equalsunit->[0x334], taking that element'sshortat+0. Falls back to the literal"<none>"andNONE. Prints"variant %d dialogue %d %s"withunit->[0x6e].if (ai_debug.render_speech)(0x1a5) — guarded onunit->[0x338] > 0; names come fromtag_get_name/tag_name_strip_pathonunit->[0x33c]anddialogue_get_vocalization_name(unit->[0x33a], 0), each falling back to"NONE".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 onunit->[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
0x2b0and deeper sat at exactly +12 in our build, so the excess was onereal_point3dallocated shallower than the buffers; - the shallow slots
0xc–0x1calready 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:
p4is genuinely ours and the reload difference is an independent residual that removing 12 bytes of stack traffic exposed; or- 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
p4and 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
0x15cin our frame as-is and everything from0x160up only at +12, which looks like an extra local occupying our0x160-0x16b. It has zero references anywhere in the function — no load, no store, not even alea. 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:
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_datumfields namesunit_datum+0x6easobject.variant_number— exactly what the"variant %d"format prints; - every speech offset lands on a field
units.halready declared, throughspeech.current,speech.queued,speech.sound_timer,speech.post_delay_timer; - the switch index table at
0x450is 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
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 inbl.encounter_pursuit_position_already_examined(encounter_index, ai_debug.selected_actor_index, history_start_time, index, &[ebp-0x2c], 0)— notesete cl/dec ecx/and ecx, eax, which is the idiom forhistory_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, thencsprintf(temporary, "%d", [ebp-0x2c]). 0x771-0x800: gated onai_debug[0x7d980] && ai_debug[0x7d3c7] && record->[1] && record->[0x34], draws two lines — yellow from the global point at0x7d988plusrecord->[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:
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
immediateflag, unlike everyrender_debug_line/render_debug_string_at_pointin this object. The count comes fromadd esp, 0xcon the_edgescall, which has no other candidate. - The
0x442cleanup isadd esp, 0x18covering 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; theactor_get(...)result at0x3b3is discarded, the same assert-only shape asunit_getincode_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_positionsis at 0x98 and.squadsat 0x80 — theebx+0x98andeax+0x80in the target — given a 12-bytetag_blockand theunused[18]run. Both already declared.squad_definition.firing_position_groupsis at 0x54, matchingtest 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 theshl esi, 6requires.field_01is already named.- Probing
struct ai_debug_statenames the three gates outright: 0xc3render_evaluations, 0xc4render_pursuit, 0xcdrender_firing_positions.
What is left before the body can be written, all mechanical:
- split
ai_debug_actor_record.__unknown00/__unknown02to name+0x00,+0x24-+0x2c(areal_point3d),+0x34,+0x38and+0x3c; - split
field_7D384[1648]to name0x7D980(boolean) and thereal_point3dat0x7D988; - locate
0x7D3C7, which falls belowfield_7D384and so is inside thepath_storage/__unknown608A9run.
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:
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 groups5and2for 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:
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.
- The assert carried bell characters. The environment note about heredocs
collapsing backslashes struck again: line 964's
match_assertfile string had single backslashes, so\acompiled to BEL — the mangled name showed?$AHwhere January has?2. The expression string also needed January's spaces (a < b, nota<b); stringize preserves source spacing. record/owner… the pointer question is per-object. January spellsai_debug.actor_record[index].fieldat every one of the fifteen uses (J=34_ai_debugreferences against our 19 — the delta equalled the use count exactly), but it does holdlong *owner_actor_index— the evaluations compare goesmov edx,[ebp-4] / cmp [edx],ecxthrough the spilled pointer. One derived pointer kept, one dissolved, in the same loop.- The owner polygon is a second quad, 0.375f out (
__real@3ec00000J=4 O=0), built at[ebp-0xec]and passed torender_debug_polygon; the 0.25f quad only feeds the edge/line fan-out. - Both quads are chained assignments, not twelve statements. The
fst/fst/fstpruns with store order c3→c0 decode ascorner[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. - The colour-count global is its own loop variable. January stores the
count on two paths (
0xb9in the peeled test's flag shadow,0x113at the loop exit) — the signature offor (global_..._count = 0; …; global_..._count++);with the global registerised, not acolor_countlocal assigned afterwards. - 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. - 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%drender is ungated with a three-arm ladderpursued ? yellow : examined ? blue : white; a fifth gateai_debug.field_7D380fronts the field_604 block; and the evaluations arm needsstring_color— NULL, assignedglobal_real_argb_whiteinside thefield_38>0arm, gating the inner render. The compiler cannot foldstring_color != NULLbecause the global's value is unknown, which is exactly the dead-lookingmov eax,[white] / test eax,eaxJanuary 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
struct path_nodeis one field short.path.hdeclares thirteen members summing to exactly 0x32, and January stamps ashortatnode+0x32(mov [ebx+0x32], axat 0xc2, read back at 0x12d), so the real struct is 0x34 with an unnamed trailingshort. 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.- The counter's identity is unresolved. It is emitted as
inc word ptr [_actor_path_debug_array + 4]. Ouractor_path_debug_arrayis astruct path_debug_storage *, so+4is past the pointer — this is either a separate adjacentstatic shortthat 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 wayunit_datum+0x6ewas 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_timelifetimes (initialiser at the declaration; the statement beforedefinition; the statement beforeencounter_get). None produces January'sor 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 longs and reals, 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.xcomputed throughnode->entry_point.x, thenentry_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 *pointvariable (January keeps each arm's lea separate; an argument ternary lets VC7 tail-merge and duplicate the call setup). Arm layout isif (!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 singlefild/fdivr 1.0and symmetric multiplies. A namedscale = 1.f/countgives 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:
code_00039990— 845/848 bytes, ONE transposition (fmul [eax]/push edxswapped 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.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. Ourstatic short global_ai_debug_path_render_iddeclared 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.)code_00041220— two surplus drawstack inlines, +208, as previously logged.ai_debug_render_actor— oneglobal_up3dreload 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,ebxexact, 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 = ¢rebefore 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 = ¢removed 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
vand attractor-block-scopev(removing one arm-scoped declaration each) - no effect, 3 bytes; a namedtext_height = point_height+0.15flocal - 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_up3dpointer 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 emitstest 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_line3drespelling 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 tdoes 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 + offset0.1f and p1 = base - offset0.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 feedingcross_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):
- Every message routes through logger 0x1082c892 -> sink 0x1082c8fb, which reads global [0x1088c2c0] and returns immediately when it is null.
- [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.
- 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:
- Software INT3 via WriteProcessMemory - plants (readback = 0xCC, persists to process exit) but never traps.
- Hardware BP via Wow64SetThreadContext - Dr0/Dr7 read back correct, never fires (WOW64 shadow debug registers).
- Hardware BP via the x64 SetThreadContext (physical DRs) - armed and read back, never fires.
- 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]withcmp 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 tofstp- 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_functionsreports 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.reserved04remains one aggregate initializer field; a nested layout namesframe_boundsat +0x0C without breakingrasterizer.c.- the encounter-only starting-location record has its own descriptive type name, avoiding a collision with game-engine source.
ai_profile_globalsnow has one header-owned definition, andai_profile.cuses 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 byhalobetacache_build,libcmt_build, andsemantic_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_admissionreports no admission candidate for AI debug. This is the expected fail-closed result: the source remainsNonMatchingand the four residuals remain open.python -m unittest discover -s tools -p 'test_*.py'passes all 179 tests.git diff --checkpasses. The changed source contains no inline assembly,volatileaddition, 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, andsemantic_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.