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