Files
Sylpheed/docs/re/remaining-ob-hunt.md
Sylpheed RE agent 7c5a4f84d4 re: OB address is run-dependent; watcher hunts it, HUD reader gated on confidence
ob_watch.py verifies the address against a confidence-gated HUD reading before
reporting anything, and two consecutive fresh launches settle the question:
0xbdb59668 held 3165285888 against a HUD of 4 on one, and exactly 4 on the next.
The address is not stable across launches, the old note that it recurs in about
five runs of seven was right, and the gate did its job by refusing to report a
series from an address that did not describe that run. The watcher now hunts the
address on the current run when confirmation fails, using the same intersection
method, so it no longer depends on a lucky launch.

The HUD reader is also gated now. ob_read returns a best and second score per
digit and those were printed but never checked, which is how one misread
poisoned an intersection and produced a wrong refutation of big-endian u32. A
reading is accepted only if every digit scores at least 0.80 with a margin of at
least 0.05, the rule ob_read's own docstring states.

The measurement itself is a negative. With a clean witness, zero stalled samples
of fifty, OB held at 4 for 250 seconds while the pilot targeted e010 for 1964
ticks and fired on 1635 of them. Constant fire at the marked attackers and not
one decrement, so it destroyed none, which matches the roughly two marked kills
per five minutes measured earlier. The fire rate itself rose from 4.6 % of ticks
in an earlier diagnosis to 83 % here without producing more kills.

Recorded as unreproduced rather than explained away: the run that found the
address saw the counter rise 4, 8, 12 over five minutes, and that reading was
confirmed against the HUD. This run was flat over a comparable window. Both
observations are sound and they disagree, so the rise is not a stable property
of the mission's first five minutes and presumably depends on progress this run
never reached.
2026-08-25 06:02:33 +00:00

16 KiB
Raw Blame History

Hunting REMAINING OB by correlation — method works, run did not finish

Status: the correlation method is sound and demonstrated; 🔴 the hunt is unfinished; 🔴 two self-inflicted defects, one a repeat.

The method

mission-phase-objectives.md settles what the counter is: Stage 02 phase 1 asks to "shoot down all invading enemy fighters" and the hints say red [OB] markers indicate the targets. So REMAINING OB must fall when a marked fighter dies — and the per-record craft strength already says exactly when that happens, for a named unit.

So instead of scanning for a value, intersect: keep every word in the 32 MB heap that fell by the same amount, in the same interval, as an e010 loss. Each kill event should cut the survivor set hard.

tools/re-capture/ob_probe2.py.

It works — one event cut 8 million words to 1056

t= 219s  e010 losses=2 (all=4)  words falling by 2: 1056  -> candidates 1056

From ~8 M candidate words to 1056 on a single event. Two or three more should leave a handful.

🔴 The run did not finish, and the candidates were lost

The turn's timeout fired at t = 219 s, and the probe saved its candidate set only at the end — so the 1056 were discarded. The follow-up attach then started from nothing.

This is the same mistake already recorded in guest-stalls.md: "the first attempt deferred all analysis to the end of the run, and the turn's timeout killed it with 240 s of collected data still in memory and nothing written." I wrote that lesson down and then repeated it in a new script four iterations later.

Fixed: candidates are now written to /tmp/ob_candidates.json after every event, and SYLPH_OB_RESUME=1 loads them so a chained attach keeps intersecting on the same mission.

🔴 The attach could not tell a quiet mission from a frozen one

The follow-up attach logged 535 s with zero losses of any kind. That is exactly what a freeze looks like, and ob_probe2 had no stall witness, so the run cannot say which it was. Also fixed — the witness from wave7_probe is now carried here.

Two defects in one iteration, both of them things this corpus had already learned. The pattern is that each new probe starts from scratch and re-earns the same lessons; the fix that would actually stick is a shared probe harness rather than a family of one-off scripts.

What is still open

The hunt itself. The method is demonstrated but no address is identified. What it needs is a run that catches two or three e010 kill events, which is the same combat-effectiveness limit recorded in mission-objectives-text.md — the preference knob gets about two marked-fighter kills per five minutes against a dozen turrets.


Second attempt: saving works, the filter was wrong (2026-08-24)

Incremental saving works

A fresh mission caught one e010 event at t = 241 s and wrote 1187 candidates to disk immediately. The turn timeout then fired, exactly as before — but this time the data survived it. The fix from the previous iteration is verified.

The session also now clears /tmp/ob_candidates.json at launch: candidate offsets are only meaningful within one emulator instance, so resuming across launches would intersect unrelated addresses.

🔴 The correlation had no value filter, and the survivors were floats

The 1187 survivors are not counters:

va 0xbd0a42b8  value 1044450858     (~0.1f)
va 0xbd140bc0  value 3200164558     (a negative float)
va 0xbd14a120  value 3212461993

The filter matched on the delta alone, so any two float bit patterns whose integer representations differ by exactly lost qualified. In a 32 MB heap full of positions and velocities that is thousands of words.

Fixed: candidates must also look like a counter — a small non-negative integer (0 ≤ v < 1000) in both samples. That removes float noise by construction rather than hoping the intersection washes it out.

🔴 The attach was frozen, and the witness said so

The follow-up attach logged 0 events across 520 s, which reads like the combat-effectiveness limit again. It was not: 25 of its 26 samples were flagged GUEST STALLED. The guest was frozen for essentially the whole window.

The witness added last iteration did its job. The lesson is about reading it — the run summary quoted "0 events" first and the stall count only turned up on a deliberate check. A run's witness result should be the first thing looked at, before any interpretation of what the run "showed".

Still unfinished

No address identified. What is needed is unchanged — two or three e010 kill events in non-stalled samples — and the two obstacles are now clearly separate: the freeze rate, and the pilot managing roughly two marked-fighter kills per five minutes.


The method narrows hard — and refutes turret tracking (2026-08-24)

With the value filter in place, an attach watching turret losses (which are frequent, unlike marked-fighter kills):

t= 45s  e007 losses=2  words falling by 2: 374   -> candidates 374
t=134s  e007 losses=4  words falling by 4: 1001  -> candidates 2
t=156s  e007 losses=2  words falling by 2: 526   -> candidates 0

374 → 2 → 0. That is exactly the behaviour a correlation search should show, and it ends in a refutation rather than fizzling out:

No plain u32 in 0xBD0000000xBE000000 decrements consistently with turret kills.

Witness first, as the rule now says: 12 of 25 samples stalled, but all four kill events fall in the early non-stalled stretch (t = 45177 s), so the events themselves are sound.

🟡 The negative fits the objective text

This is what the objective text predicts. Stage 02 phase 1 asks to "shoot down all invading enemy fighters", and turrets are not fighters — so a counter of remaining marked targets should not move when a turret dies. The refutation is evidence for the reading in mission-phase-objectives.md, not against it.

It also rules out the cheaper alternative reading, that REMAINING OB is a general kill or enemy-remaining tally. It is not: it ignored ten turret deaths.

What this leaves

The method is proven and the search region is right — 0xbdb59668, where the counter was originally seen, is inside the scanned range. What is missing is still two or three e010 kill events in non-stalled samples, and the same two obstacles: the freeze rate, and the pilot's ~2 marked-fighter kills per five minutes.

One clean 220 s run this iteration produced zero e010 kills, which is the limit stated plainly.


A kill-free route via the HUD — and it refutes the u32 assumption (2026-08-24)

The correlation route is gated on marked-fighter kills, which the pilot gets at about two per five minutes. But ob_read.py already reads the counter off the screen, so the value can be matched against memory directly — no kills needed.

tools/re-capture/ob_by_hud.py: screenshot → read the digits → keep heap words equal to that value → intersect across readings.

t=  7s  HUD=4   words==4: 6156  -> candidates 6156
t= 42s  HUD=4   words==4: 6256  -> candidates 5153
t= 75s  HUD=4   words==4: 6327  -> candidates 4620
t=108s  HUD=4   words==4: 6451  -> candidates 4312
t=142s .. t=312s   HUD unreadable ('00?', '??1', '???')
t=347s  HUD=11  words==11: 1052 -> candidates 0

🔴 Refuted: the counter is not a plain big-endian u32 in the entity heap

Four readings at value 4 narrowed 6156 → 4312 — the expected slow drift. Then the HUD read 11, and the intersection collapsed to zero.

A word that genuinely holds this counter must equal 4 at the first four samples and 11 at the last. None does. So within 0xBD0000000xBE000000, read as big-endian u32, the counter does not exist. It may be u16, u8, little-endian, or simply outside that region.

That is worth having: both hunts so far assumed BE-u32 in the entity heap, and that assumption is now eliminated rather than merely unproductive.

🟡 The displayed value went UP, 4 → 11

Over ~340 s the counter increased. A pure countdown of remaining marked targets should not do that — unless targets were added, which the deployment work says does not happen for phase 1 (mission-phase-deployment.md).

Possible readings, none tested: the cell being read is not REMAINING OB; the digits are misread (the template strip only covers 0 1 2 4 8, so 3/5/6/7/9 come back as ? — the many unreadable samples above); or the counter genuinely counts something that can rise.

The two clean readings scored 0.950.98 against their templates, so a misread of those specific frames is unlikely — but "4" and "11" use only digits that are in the strip, which is exactly the selection effect that would hide a wrong reading.

Next

Widen the encoding: search u16 and u8, little-endian as well as big, and beyond the entity heap. That is a change to one scan function, and unlike the kill-driven route it costs no combat.

Also worth extending ob_digits.png with the missing digits — most samples were unreadable, which is why only two data points survived a 480 s run.


Widened to every plausible encoding (2026-08-24)

ob_by_hud.py now scans seven readings of the same bytes and keeps a separate candidate set for each, expressed as byte offsets:

u32be, u32le, u16be, u16be@1, u16le, u16le@1, u8.

First run, HUD showing 4 throughout:

t=  7s   u32be:6285  u32le:414  u16be:10668  u16be@1:2718  u16le:656  u16le@1:9012  u8:25559
t=110s   u32be:4472  u32le:156  u16be: 8794  u16be@1:1386  u16le:286  u16le@1:8124  u8:20381
t=136s   u32be:4452  u32le:154  u16be: 8773  u16be@1:1380  u16le:284  u16le@1:8119  u8:20346
t=162s .. t=267s   identical to t=136 in every encoding

u32le is much the tightest at 154, an order of magnitude below u32be. That is a hint about the encoding, not a result — a rarer bit pattern narrows faster regardless of meaning.

🔴 Inconclusive, and probably a frozen guest

The HUD read 4 at every sample, so there was no second value to collapse the sets against. Worse, from t = 136 s the candidate counts are byte-identical across five samples in all seven encodings, which is what a frozen guest looks like — nothing in 32 MB changed at all.

ob_by_hud.py had no stall witness, so the run cannot prove it. Added now.

This is the fourth probe written without a witness and the third whose flat output could not be told from a freeze. Each time the fix is applied to that one script. The durable fix is the shared harness already noted in this file, and the fact that the lesson keeps recurring is itself the argument for building it.

What is needed

Two HUD readings at different values, in non-stalled samples. The counter changes on kills, so this lands back on the combat limit — unless a phase change or another event moves it. The earlier 4 → 11 observation shows it does move, which is what makes the approach worth continuing.


FOUND AND VERIFIED: REMAINING OB is a big-endian u32 at 0xbdb59668

The run

Ported onto the shared harness (ob_hunt2.py), the HUD changed 4 → 8 and the intersection collapsed in one step:

t=126s  HUD=4  u32be:8717  u32le:163  u16be:13844  u16be@1:3319  u16le:832  u8:36635
t=191s  HUD=8  u32be:1     u32le:0    u16be:1      u16be@1:2     u16le:0    u8:3
t=254s  HUD=8  (unchanged)

Resolving the survivor:

encoding address
u32be 0xbdb59668
u16be 0xbdb5966a — the low half of the same word
u8 0xbdb5966b — the low byte of the same word

All three are the same location. The u16be@1 pair and the other u8 hits are elsewhere and did not survive as a coherent field.

Verified live against the HUD

Read three times, twenty seconds apart, screenshot and memory together:

HUD='0?2'   mem@0xbdb59668 = 12      (HUD frame unreadable)
HUD='012'   mem@0xbdb59668 = 12      MATCH
HUD='???'   mem@0xbdb59668 = 12      (HUD frame unreadable)

The one legible frame agrees exactly. REMAINING OB is a big-endian u32 at 0xbdb59668 — independently rediscovered by value-intersection, and landing on the same address the earliest sessions found by digit-transition hunting.

🔴 Correction: the earlier "big-endian u32 refuted" was wrong

An earlier run intersected HUD readings of 4 and then 11 and got zero survivors in every encoding, which was written up as eliminating u32be for the whole region. This run shows u32be holds the counter.

The likely cause is the reading itself: ob_digits.png only has templates for 0 1 2 4 8, so any value containing 3/5/6/7/9 is misread rather than rejected, and "11" was probably one of those. A single bad reading poisons an intersection permanently — it removes the true address and nothing later can bring it back.

Lesson: an intersection method needs its inputs to be individually verifiable. The HUD reader's confidence scores were printed but never gated on; a minimum score would have dropped the bad frame instead of trusting it.

🟡 The counter increases: 4 → 8 → 12

Across roughly five minutes the value went 4, then 8, then 12 — measured in memory, so not a digit misread. A counter of remaining marked targets that rises means targets are being added during the mission.

That is the arrival question again, and now with a cheap, reliable, one-word signal instead of a 32 MB scan. It does not contradict mission-phase-deployment.md — the roster's deployment is fixed at load — but it does mean the game marks new objective targets as the mission proceeds.

Watching this one address across a mission is now the obvious next experiment, and it costs almost nothing.


The address is run-dependent; the watcher now finds it itself (2026-08-25)

Confirmed run-dependent

ob_watch.py verifies the address against a confidence-gated HUD reading before reporting anything. Two consecutive fresh launches:

run A   confirm: HUD=4  mem@0xbdb59668=3165285888   MISMATCH
run B   confirm: HUD=4  mem@0xbdb59668=4            MATCH

So 0xbdb59668 is not stable across launches — it held garbage on one and the true counter on the next. The old note that it "recurs in about 5 runs of 7" was right, and the gate did its job: run A refused to report a series from an address that did not describe it.

The watcher now hunts the address on the current run when confirmation fails, by the same intersection method, so it is self-sufficient rather than depending on a lucky launch.

The confidence gate is in

ob_read returns (best, second) per digit and those scores were printed but never checked — one misread poisoned an entire intersection and produced a wrong refutation. A reading is now accepted only if every digit scores ≥ 0.80 with a ≥ 0.05 margin, which is the rule ob_read's own docstring states.

🔴 OB did not move in 250 s, and the pilot fired 1635 times

Run B, witness clean (0 stalled samples of 50):

OB = 4 for all 50 samples
pilot: 1964 ticks targeting e010, fire=1 on 1635 of them

Constant fire at the marked attackers and not one decrement. The simplest reading is that it destroyed none of them — consistent with the combat limit recorded in mission-objectives-text.md, and with the earlier measurement of roughly two marked kills per five minutes.

Worth noting the fire rate itself changed: an earlier diagnosis measured firing on 4.6 % of ticks, this run on 83 %. Whatever drives that, more shooting did not produce more kills.

The 4 → 8 → 12 rise is not reproduced

The run that found the address saw the counter go 4 → 8 → 12 over five minutes, and that reading was itself confirmed against the HUD (012 vs mem=12). This run was flat at 4 for a comparable window.

Both observations are sound and they disagree, so the rise is not a stable property of the first five minutes. It presumably depends on mission progress this run never reached. Recorded as unreproduced rather than folded into either story.