Files
BFM-decomp/docs/struct-core-pivot.md
T
Drew T 1431b523a4 feat(phase-17): T4+T5 — actor struct recovered (comprehension) but byte-neutral for matching
- recovered the engine actor/player struct cold via PCSX-Redux (R10/R11): base 0x80078E00,
  ~154 fields over 0x24C bytes, 32 pointer fields; HP/BP/gold/day/hour/position/BP-drain all
  live-verified and anchored to the Phase-3 player state + the walk diff. -> docs/actor-struct.md
- tools/ram_probe.py: PCSX-Redux RAM capture/diff/read/ptr-scan workhorse for field-typing
- FINDING (the matching verdict): feeding the struct to m2c as --context is BYTE-NEUTRAL.
  m2c adopts it (arg0->f_X) but structural-miss sample scores 0 better / 10 same / 2 worse vs
  m2c-alone (full code+RAM pointer set). The misses are 50-98% mismatched = m2c decompiler
  limits, not missing types. T4 fn-ptr context unblocks compilation but functions stay structural.
- conclusion: rich context (struct/type recovery) is a comprehension / decomp.me win, NOT a
  byte-match lever -> Phase-16 wall re-confirmed from the type angle. struct-core-pivot.md updated.
- emulator field-naming pass deferred (comprehension, off the matching path); NEXT: T6 permuter
  validation on the 146 genuine near-misses, then T7 go/no-go.
2026-06-19 13:03:14 -06:00

9.6 KiB
Raw Blame History

Struct-Heavy Shared Core — Findings & Pivot (Phase 16)

Status (2026-06-19): the m2c + decomp-permuter brute-force approach will not crack the struct-heavy shared core. It yields a few percent, bounded by a fundamental wall (loose typing), not by a fixable bug. Decision (Drew): pause this approach, document, and plan a new idea. This file is the durable record + the seed of the next plan. The harness bug-fixes are real and kept.

Phase 17 / T1 update (2026-06-19): the wall is now quantified by a full census of all 957 residual shared stubs — see docs/wall-taxonomy.md. Headlines: the loose-typing wall is ~99 functions (10%) (not the whole tail); the biggest addressable lever is rich m2c context (struct + jump-table + fn-pointer-table types), reaching ~67% — i.e. Direction A/B's value is as m2c --context, not Ghidra-C, exactly as the deep-research revised. The named "DATA-symbol header" lever (§14c-c) addresses 0 functions and is dropped. sig_unify alone (no permuter) re-derives ~5%, matching this doc's ~3% finding.

Phase 17 / T4-T5 update (2026-06-19): the rich-context thesis was tested and is byte-neutral. The actor struct was fully recovered (base 0x80078E00, ~154 fields, 32 pointers; live-verified via PCSX-Redux, anchored to Phase-3 — see docs/actor-struct.md), m2c adopts it (arg0->f_X), but on a structural-miss sample it scored 0 better / 10 same / 2 worse vs m2c-alone (identical bytes; the misses are 50–98% mismatched = m2c decompiler limits, not missing types). The fn-ptr-table context (T4) unblocks compilation for the 85 fnptr-call functions but they remain structural misses underneath. Conclusion: Direction A/B (struct/type recovery) is a comprehension/decomp.me win, NOT a byte-match lever — Phase 16's wall re-confirmed from the type angle. The last untested byte lever is the permuter on the genuine 146 near-misses (T6).

Objective (unchanged)

Match the ~964 remaining shared engine functions in ov_SC01_077 (struct-heavy; each propagates ×134 overlays via dedup_propagate). Cracking them would lift the fleet from 54.5% toward ~90%.

What we built and tried (the pipeline)

m2c --valid-syntax (+ common.h byte-faithful macros M2C_FIELD/NULL/s64) → sig_unify (canonical signatures) → match_one (fast prefilter) → decomp-permuter (regalloc/schedule search) → harvest_verify whole-binary byte-gate (the sole arbiter, G3) → dedup_propagate (fleet-wide). Driver + supervisor + safe-exit (tools/auto_*.{py,sh}) built for an unattended 5-day run.

Harness bugs found and fixed (REAL improvements — kept regardless of direction)

The byte-gate caught a string of false positives; each fix is committed and correct:

  1. Permuter base.c stripped callee externs → compiled with implicit-int callees → matched in the wrong signature context. Fixed (make_base_c keeps the canonical externs).
  2. run_permuter counted output-<score> dirs as matches — only output-0-* is a true byte-match; it was calling score-20/145 "best" results "MATCH" (this produced the illusory overnight "42%"). Fixed.
  3. sig_unify missed m2c's no-extern prototypes (M2C_UNK func_X(void *); /* extern */), so callee types stayed m2c's guess and the whole-binary build hit conflicting types. Fixed (PROTO_DECL_RE).
  4. winner_to_draft string-replace failed (permuter reformats typedefs one-per-line) → typedef redefinition. Fixed (line-filter strip). These got the harness from "false 42%" to "honest ~1 in 8 on a controlled sample."

The fundamental wall: the original code is LOOSELY TYPED

The original BFM engine was written in K&R-style / loosely-typed C (PsyQ-era). Evidence (byte-checked):

  • The same function is called with an integer at one site and a pointer at another, and with an argument at one site and no args at another (e.g. func_8012AAAC is defined void func(void) but called as func_8012AAAC(arg0) from a matched caller — and the caller is byte-correct).
  • A global canonical-decls header (the planned "lever 1") forces ONE signature per function and therefore breaks the existing matches (passing arg makes integer from pointer without a cast across many already-matched functions). Confirmed and reverted.
  • For many functions the body is byte-correct in isolation (match_one MATCH) but there is no C declaration that simultaneously (a) matches the canonical definition and (b) supports the call site. These are not fixable by any harness change — they'd need per-call-site type surgery or hand inline-asm.

Why this yields ~3%, not the crack

  • The easy/consistently-typed functions were already matched by Phase 15. What remains is disproportionately the loose-typed hard class.
  • m2c is a per-function decompiler with weak type inference; it guesses a prototype per call, and on loose-typed code those guesses are mutually inconsistent across the TU.
  • The permuter only fixes register allocation / scheduling — it cannot fix a wrong callee type, a wrong symbol, or a structural mis-decompilation, which is what the residual actually needs.
  • The byte-gate correctly rejects the subtly-wrong drafts (this is good — zero false matches — but it means the yield is genuinely low, not artificially low).
  • Measured gate-verified rate on controlled samples: ~1–2 of 8–16. A 5-day run would bank a modest fraction (single-digit to low-double-digit percent of the 964), ×134 propagation ⇒ a few fleet points.

Decision

Pause the m2c+permuter brute-force as the "crack." Optionally still run it during the away window for the cheap few percent (the byte-gate guarantees correctness) — but only after the harness is hardened, and understood as a consolation, not the solution. The real next step is a new approach + new research.


New directions to research (the next plan)

The root cause is lost type information. The compiled binary discarded the structs/signatures the original C had; m2c can't recover them from one function at a time. The promising directions all attack that — recover or model the real types — rather than brute-forcing past them.

Direction A — Ghidra's decompiler + GLOBAL type propagation (most promising)

We used m2c (lightweight, per-function). But the overlays are already imported into Ghidra (Phase 13), and Ghidra's decompiler does whole-program type propagation — define the actor/entity struct(s) once and Ghidra propagates field/param types across all functions, producing consistent signatures (exactly what the loose-typing wall needs). Most mature decomps (sotn-decomp) start from Ghidra's C, not m2c. Plan: define the actor struct in Ghidra (from analysis + Direction B), let it propagate, export typed C, then permuter+byte-gate. Test on the known-answer ladder.

Direction B — Emulator-guided struct & type recovery (attacks the root cause)

PCSX-Redux (our runtime oracle, R11) can reveal the true struct layout and field semantics by watching the running game (R10 multi-datapoint): which offsets are pointers vs scalars vs arrays, field widths, and the real argument types at call sites. This recovers the type information the binary lost. Feed it into Ghidra (Direction A). This is the backlog's "emulator time-capture," elevated from quality-nicety to the enabling step — without real types, no decompiler (m2c or Ghidra) can produce consistent matchable C.

Direction C — Model the original's K&R / no-prototype declaration style

The original almost certainly used K&R declarations (func_X();, no arg types), which is why loose calls compiled. The textbook () escape failed for default-promotion params (s8/s16/u8/u16/float) — but that's a minority. Research: how do other PSX/PsyQ-era matching decomps (sotn, mednafen-era titles) handle K&R-style engine code at scale? There may be a known declaration pattern (or a maspsx/compiler-flag trick) that lets loose calls match without a global canonical signature. Treat web sources as untrusted (X2).

Direction D — LLM-in-the-loop WITH gate feedback (narrow, re-evaluated)

Earlier we (correctly) dismissed ML for one-shot byte-exact generation. A different, untried shape: an agentic loop where the model proposes a draft, sees the byte-gate diff, and revises — gate-supervised, not blind (§14d's poor ROI was measured on blind drafting). Possibly with the typed Ghidra C as the seed. Low priority vs A/B, but a bounded experiment once types exist.

Research to run (deep-research skill, X2)

  1. Ghidra-decompiler matching workflow — struct definition + type propagation + export, as used by sotn-decomp / other PS1 decomps; tooling that bridges Ghidra C → compilable matching C.
  2. Loose-typed / K&R PSX engine matching — community idioms for inconsistent call-site types.
  3. PS1/Square actor-struct recovery — has any Square PS1 decomp recovered an entity struct, and how?

Recommendation

  1. Pause the brute-force as the crack (this file).
  2. Optionally, harden the harness and run the modest brute-force during the away window for the free few percent (cheap compute, correctness gate-guaranteed) — a consolation, not the plan.
  3. Make Direction B → A the new plan: emulator-recover the actor struct + field/arg types → define in Ghidra → global type propagation → Ghidra-C → permuter + byte-gate. Run the deep-research first to ground it in how the community does it. This attacks the root cause (lost types) instead of brute-forcing past it.