Files
psxrecomp/docs/internal/BACKDROP_PRELOAD.md
T
Matthew Stanley e6ce5006b4 docs: move root and stale docs into docs/ and docs/internal/
The repository root carried seven markdown files that were not onboarding
material, plus two that had been superseded by the code they described. This
sorts them: reader-facing docs to docs/, working notes to docs/internal/, and
the two superseded ones merged into the docs that replaced them.

Moved to docs/:            WIDESCREEN.md, ENHANCEMENTS.md, TCP_COMMANDS.md
Moved to docs/internal/:   PRINCIPLES.md, ISSUES.md, DEBUG.md, GTE_LM_FIX.md,
                           and from docs/: bios-hardening.md, overlay-status.md,
                           overlay-plan.md, overlay-recompilation-design.md,
                           overlay-discovery.md, TOMBA2_EXC_CONSULT_RESPONSE.md,
                           CASE_A_AOT_GAP.md, audit_inventory.md
Moved to docs/internal/upstream/: UPSTREAM_2026-08-20.md

The root keeps only what belongs there: README.md, CONTRIBUTING.md, LICENSE,
CLAUDE.md, THIRD_PARTY_ATTRIBUTION.md, RELEASE_NOTES.md, the dotfiles and
psxrecomp_cli.py.

MERGES

SPLITGEN_MIGRATION.md folded into docs/BUILDING.md as "Split generated C". Its
migration instructions were stale in a way that mattered: it told every game
repo to hand-edit its CMakeLists and add a file(GLOB ...) over
<serial>_full*.c, but runtime/runtime.cmake now does that itself. GEN_FULL_GLOB
is a multi-value argument of psxrecomp_add_game_runtime(); the loop at
runtime.cmake:1985 makes each pattern absolute against CMAKE_CURRENT_SOURCE_DIR,
expands it with file(GLOB), and concatenates the hits into
GAME_GENERATED_FULL_C. So a title names the pattern and gets however many shards
a regeneration produced. The rewritten section describes that, the two-step
fallback when the globs match nothing (GEN_FULL_FALLBACK, else <serial>_full.c
derived from the dispatch marker by substituting _dispatch.c), and the one
caveat the code actually has: that file(GLOB) is plain, not CONFIGURE_DEPENDS,
so a regeneration which changes the shard COUNT needs a re-configure to be seen.

psxrecomp-toml-readme.md folded into docs/GAME_PROJECT_SETUP.md as "EXE-only
autofill (psxrecomp-toml)", placed after "Disc autofill (probe_disc.py)" and
before "Project Studio" so the two autofill paths sit together. Its documented
flags were checked against the usage text in recompiler/src/main_toml.cpp:28-42
and against the argument parser at :150-165 -- all seven options match with no
drift, so the flag list is reproduced as-is. Added a pointer that probe_disc.py
is preferable when a cue exists, since that tool does the same EXE-header work
plus disc identity and netplay gates.

FUNCTIONAL FIXES

tools/gen_tcp_commands.py wrote to REPO/"TCP_COMMANDS.md". After the move that
path does not exist, so the generator would have created a new stray root file
and --check would have failed against it forever. Retargeted to
REPO/"docs"/"TCP_COMMANDS.md", along with the seven docstring and message
strings that name the path.

Running it surfaced two further problems, both fixed here:

  * The doc was genuinely stale -- pre-existing drift, not introduced by this
    commit (the moved file is byte-identical to origin/master, and no
    debug-server command table is touched here). Four commands registered since
    the last regeneration were missing from the index -- cdrom_timing_dump,
    savestate_status, ws_ui_groups, xprobe_watch -- and scanline had gained a
    backend. Regenerated: 307 -> 311 commands, and the change is confined to the
    AUTOGEN block (the hand-written head and tail are byte-identical to master).
    tools/gen_tcp_commands.py --check now exits 0.
  * DOC.write_text() has no newline argument, so Python translated every newline
    to os.linesep. On Windows that rewrote all 585 lines of the file to CRLF and
    turned a four-line index update into a whole-file diff -- measured: 589 CRLF
    / 0 LF after a run, against 0 CRLF / 585 LF in the repo. Now written through
    an explicit newline="" handle, verified to leave the file at 0 CRLF.

runtime/include/debug_server.h:9 and runtime/src/debug_server.c:8 cited "TCP.md
and DEBUG.md". TCP.md has never existed in this repo under that name; both now
name docs/TCP_COMMANDS.md and docs/internal/DEBUG.md.

LINK AND REFERENCE FIXES

Every inbound reference to a moved file was located by grepping each basename
across the whole tracked tree, not just the markdown. Markdown hrefs in
README.md, CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/COMPILING_OVERLAYS.md,
docs/AOT_OVERLAY_PLAN.md, docs/RELOCATION_MANIFEST_FORMAT.md,
docs/config_schema.md, docs/OVERLAY_XREF.md, docs/TCP_COMMANDS.md and several
docs/internal/ files now point at the new locations; where two files became
siblings under docs/internal/ the path was shortened rather than lengthened
(bios-hardening.md's reference to overlay-discovery.md, for instance).
docs/COMPILING_OVERLAYS.md's "in this folder" phrasing was wrong once two of the
three files it listed moved, so it now names internal/ explicitly.

24 source-comment mentions of ENHANCEMENTS.md sections G1.x across 19 files
(recompiler, runtime, cmake) were retargeted to docs/ENHANCEMENTS.md with the
section numbers untouched. docs/SHADOW_ENHANCEMENTS.md is a DIFFERENT file and
was deliberately excluded.

20 rationale strings citing docs/CASE_A_AOT_GAP.md across the four
recompiler/seeds/*.json files were updated. Done as a byte-level substring
replacement rather than a JSON round-trip: two of those files are stored with
CRLF endings and two with LF, and re-serializing them reformatted 5,700 lines.
The byte-level version changes exactly 20 lines and each file still parses.

References that look similar but point elsewhere were deliberately left alone,
and are worth naming so a future pass does not "fix" them:

  * "recomp-template PRINCIPLES.md" / "NES recomp template PRINCIPLES.md" in
    CLAUDE.md:43, docs/SHADOW_ENHANCEMENTS.md:28,
    docs/internal/HLE_SCHEDULER_CARVEOUT_PLAN.md:5,
    runtime/src/debug_server.c:4934 and tools/parity_diff.py:8 are a different
    repository's file. Only the five that mean this repo's PRINCIPLES.md were
    updated.
  * Every remaining bare ISSUES.md mention is a GAME repo's tracker
    (MegaManX6Recomp 2/7/8/9, TombaRecomp 6), not this repo's.
  * docs/disc-speed.md:4,54,56 and docs/internal/bios-hardening.md:5 name
    overlay-discovery as a BRANCH, not a file, so no path applies.

INDEXES

README.md's "New here?" table gains rows for docs/WIDESCREEN.md,
docs/ENHANCEMENTS.md and docs/TCP_COMMANDS.md. docs/internal/README.md gains
sections indexing all fourteen documents that moved in, plus an "Upstream
contributor reviews" section describing upstream/ and the new sweep file, plus a
"Removed" bullet for CYCLE_TIMING_ARCH.md (deleted in the previous commit)
pointing at FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
PRECISE_IRQ_SLICE.md -- following the SLJIT_PERSIST_CACHE.md precedent already
in that file.

docs/ENHANCEMENTS.md:7 claimed framework bugs live in "game repos' ISSUES.md
until a framework tracker exists". A framework tracker does exist; it now says
so and names the Beads epic.

VERIFICATION

A link checker was written to scan every tracked *.md for relative links,
resolving each against the tree being scanned. Baseline at origin/master: 129
files, 137 relative links, 3 broken. After both commits: 127 files, 159 relative
links, 3 broken -- the SAME three, all pre-existing and untouched here:

    docs/DATA_SHARDS.md -> INSTANT_LOADS_PARKED.md   (never existed)
    docs/DATA_SHARDS.md -> INSTANT_LOADS_SPIKE2.md   (never existed)
    docs/NETPLAY.md     -> ../lib/recomp-net/README.md  (inside a submodule)

Zero new broken links. The checker's own coverage was checked against a raw grep
for markdown link openers: the 3-link difference is C++ lambdas inside code
blocks, correctly not treated as links.

Also verified: py_compile over all 113 tracked tools/*.py plus psxrecomp_cli.py;
all four seed JSONs still parse; the test-registration guard still reports 123
test files all registered; recompiler/tests/test_coverage_vault.py still passes;
and no file in this commit had its line endings flipped (checked mechanically
across all 50 modified files, after two files were caught and redone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:07 -07:00

327 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Generic backdrop preload (widescreen) — design + implementation plan
> **2026-06-16: ROOT CAUSE CORRECTED — read the "2026-06-16" section at the bottom
> FIRST.** The column-preload design below was the wrong lever for the 16:9 void;
> the real cause is native-wide 2D-backdrop POSITIONING and the fix is a GL
> wide-mirror geometric stretch gated to the backdrop (mechanism proven; precise
> classification via `gp0_cmd_source_addr` / sprite-tag is the remaining step).
Branch: `feat/ws-backdrop-coverage` (both repos, off master). Goal: in 16:9,
Tomba's far-BACKGROUND tile grid (sky/ground/cloud/flower-field layers) only
generates a camera-centered ~4:3 window of tile columns, so the revealed right
(and a little of the left) margin shows void until the camera moves. User
directive: **eliminate the culling / preload the entire background** (free on
modern HW; the extent is a finite tile table), as a **generic auto-detect** that
works across all scene overlays and is reusable for any PSX game.
## Root cause (fully diagnosed 2026-06-15)
The backdrop generator is **overlay-resident AND scene-specific**: each area's
overlay carries its own copy of a structurally-identical column-window generator
at its own addresses (the village's is `FUN_80116a28`; the sky/flower-field
scene's is `~0x80116800`; the bytes at a given address differ per scene). The
main EXE has NO camX cull — it tail-dispatches to these overlay handlers
(verified by byte-searching every `0x176` consumer in SCUS_942.36).
Per window, the generator computes a camera-windowed column range:
```
camX = lh/lhu rC, 0x176(rB) ; camera world-X
(opt) addiu rC, rC, Koff ; per-layer parallax bias (varies)
start = (camX+Koff) / 96 ; signed reciprocal-multiply:
lui rM,0x6666 ; ori rM,rM,0x6667 ; (magic 0x66666667)
mult rC,rM ; mfhi rH ; sra rQ,rH,5 ; subu rQ,rQ,(rC>>31)
move rStart, rQ ; START finalize
end = addiu rEnd, rStart, N ; N = 4:3 window width (8..12; some 44/70)
<start clamp low> ; bgtz->li 1, or bgez->clear 0
<end clamp high> ; slt rEnd,rExt -> addiu rEnd,rExt,-1
; (rExt = lbu tile-table length, finite)
; or slti rEnd,Cext -> li rEnd,Cext-1 (const, finite)
inner loop: for col in [start, end): emit one tile from the finite table
```
The window slides with camX → the 16:9-revealed columns aren't generated. Fix =
force `start=0` (loop from col 0) and `end=full extent` (the existing high clamp
pins it), so the whole finite row is emitted every frame regardless of camera.
## The invariant signature (verified on BOTH the village + flower-field overlays)
Byte/opcode-STABLE anchors (match on these):
- **`0x176` camX half-word load** (`lh`/`lhu rC,0x176(rB)`) — offset invariant.
- **Magic `0x66666667`** — `lui rM,0x6666` + `ori rM,rM,0x6667`. Strongest anchor
(the /96 reciprocal is specific to these scrollers; gating on the `0x176`
camera offset essentially eliminates collisions).
- **Divide tail** `mult rC,rM` → `mfhi rH` → `sra rQ,rH,0x5` → `subu rQ,rQ,rSign`
(shift amount 5 invariant).
- **`addiu rEnd,rStart,N`** end-delta with small N, immediately after the
`move rStart, <quotient>` start-finalize.
- **End clamp** to the tile-table length register (`slt …,rExt`→`addiu rEnd,rExt,-1`)
or a literal (`slti …,Cext`→`li rEnd,Cext-1`).
VARIES (read from the matched site, never assume): all registers
(village a2=start/a3=end/t0=mfhi; FF s2=start/s1=end/a2=mfhi), the parallax
offset Koff (and whether a `jal 0x8002d964` cam-jump precedes), N (8/9/10/12 and
44/70), the start-clamp floor idiom (floor 0 vs 1 vs const), the extent source
(register `s2`/`s7` vs literal `0x20`/`0x3f`), and the number of windows per
jumptable case (village 1; FF 1–3, two-layer backdrops).
## Patch (two single-instruction rewrites per window; both confirmed clean)
1. **START finalize** `move rStart, rQ` → force `rStart = 0` when widescreen
active. The existing low clamp keeps it ≥ floor.
2. **END delta** `addiu rEnd, rStart, N` → force `N = 0x100` (≥ any 8-bit table
length) when widescreen active. The existing high clamp pins `rEnd` to
`extent-1`. (For const-extent windows, ≥ the literal ceiling, e.g. 0x3f.)
`extra=0` / widescreen-off must reproduce the original N and the original
`move` exactly (byte-identical faithful build).
Full extent is a finite `lbu` tile-table length (≤255, realistically dozens) or
a small literal → preloading the whole row is bounded and safe (no OOB; the emit
loop writes one display-list slot per column from the finite table).
## Implementation plan
### Recompiler pass (gcc/native path) — `[widescreen.cull] auto_backdrop = true`
A function-level pre-pass in `code_generator.cpp` (sibling to
`func_has_screen_extent_cull`): for each function, find every window via the
invariant (magic 0x66666667 + a `0x176` load feeding the `mult` + the
`sra…,5`/`subu` divide tail + `move rStart,quotient` + `addiu rEnd,rStart,N`).
Record a map `{addr -> {START_ZERO(reg) | END_WIDEN(rs,reg)}}`. `translate_instruction`
consults it: at START_ZERO emit `rStart = psx_ws_backdrop_preload() ? 0 : <orig move>`;
at END_WIDEN emit `rEnd = rStart + (psx_ws_backdrop_preload() ? 0x100 : N)`.
`psx_ws_backdrop_preload()` = a runtime predicate (gpu.c), nonzero only when
native-wide is engaged (0 at 4:3 ⇒ byte-identical). Robustness: the function-level
magic+0x176 gate + the divide-quotient dataflow gate make false positives very
unlikely; assert/log the per-function window count at build time.
### Overlay coverage (the generators are overlay-resident)
- The recompiler pass must run for the OVERLAY compile too: `compile_overlays.py`
already forwards `--ws-config` (game.toml), so `auto_backdrop` reaches it. Bump
`PSX_OVERLAY_CODEGEN_VER` and rebuild the overlay cache (gcc/<arch-abi>/cgN).
- Backends: a backdrop generator may run as a gcc-cache DLL (pass handles it),
a sljit shard, or interpreted. [sljit was removed 2026-07-15 (`5b7e69b4`); the
toolchain-free tier is now bundled tcc, which also produces a DLL — so only the
DLL and interpreted cases exist today. See `runtime/src/overlay_backend.c`.]
For full coverage like `auto_screen_x`, the
sljit emitter + dirty_ram_interp need the same force-start-0 / widen-end at the
detected sites. Options: (a) export the detected site map to the runtime so
sljit/interp can apply it, or (b) re-derive the signature in a sljit/interp
pre-scan (heavier). NOTE: in dev, overlays often run interpreted — so the
interp hook is needed to SEE the fix in the debug build.
### Verify
Debug build (build-stable, TCP) navigated to the flower-field/sky-platform scene
(user drives). Confirm the right-margin void fills (full row preloaded) and 4:3
stays byte-identical. Extracted overlay for offline analysis:
`_shots/overlay_flowerfield.bin` (base 0x800E7000); also imported into Ghidra as
`overlay_flowerfield.bin`. Village reference: `overlay_800E7000.bin` /
`_shots/overlay_800E7000.bin`.
## Status
IMPLEMENTED 2026-06-15 on `feat/ws-backdrop-coverage` (all 4 execution paths);
LIVE-TESTED then PIVOTED — see "Pivot" below; pending re-verify + merge. Related:
`../WIDESCREEN.md`, `NATIVE_WIDE_PLAN.md`, memory `ws_draw_census_8c` /
`native_wide_fov_autocull`.
### Pivot (2026-06-15, after first live test)
First approach forced START→0 / END→0x100 (draw the WHOLE finite row). Live test:
village backdrop void FIXED but **laggy** (whole-row × 4 layers = massive
overdraw, amplified because the generator runs interpreted in the dev build), and
the watchtower scene still **culled on the right** (its row > the fixed 0x100
cap). Switched to **camera-tracked window WIDENING**: keep the camera-relative
window, extend START left / END right by a margin in COLUMNS sized to the 16:9
reveal. The detector now also records `window_cols` (= |addiu offset|, the
window width); `psx_ws_backdrop_value(orig, is_end, window_cols)` shifts the bound
by `margin = window_cols * nw_offset / disp_w + 2`. Elegant property: per-side
coverage = margin_cols × col_width = (N·nw_offset/disp_w)·(disp_w/N) = nw_offset
px = EXACTLY the reveal, for every layer regardless of window_cols (+2 col slack
for truncation/pop-in). Draws only the now-visible columns → no overdraw (fixes
lag), no fixed cap (fixes the right-gap). Codegen ver 2→**3**, ABI value callback
gains the window_cols arg.
Also fixed a pre-existing blocker the boot smoke-test exposed: the explicit
`[widescreen.backdrop] x_sites` / `[widescreen.cull] *_sites` shape checks
hard-`exit(1)` on a non-matching opcode, but overlay regions reuse the same
address for different code across scene VARIANTS (x_site 0x8012196C is `sh` in one
variant, `addiu` in another). Added `CodeGenConfig::overlay_mode`: in overlay mode
a shape mismatch SKIPS the transform (applies it only where bytes match) instead
of killing the whole overlay-variant compile; main-EXE mode keeps the hard error.
### What shipped (uncommitted on feat/ws-backdrop-coverage)
- **Shared detector** `runtime/include/ws_backdrop_detect.h` — self-contained C
(stdint only), `psx_ws_find_backdrop_windows()`. ONE source of truth for all
paths (recompiler includes it via relative path — runtime/recompiler both have
a `gte.h`, so no include-dir merge). Gate per `mult`: bounded BACKWARD scan for
the exact magic (lui 0x6666 + ori 0x6667) into one operand + a `lh/lhu …,0x176`
into the other (the magic is sometimes set in a shared branch-delay slot, so a
forward-accumulating scan corrupts on the branch — backward-per-mult is the fix);
forward divide tail (mfhi→sra,5) + the move/addiu consumers. **Role by OFFSET
ORDERING** (smaller offset → START→0, larger → END→0x100), NOT move-vs-addiu:
village + flower-field each have a window where the `addiu` (negative offset)
is START and the `move` is END. The plan's "move=START/addiu=END" was WRONG.
- **Runtime** `gpu.c`: `psx_ws_backdrop_preload()` (= `ws_native_wide_active()`,
0 at 4:3/squash/FMV) + `psx_ws_backdrop_value(orig,is_end)` (identity unless
preload → 0/0x100). One helper, all backends, like `psx_ws_cull_sltiu`.
- **gcc/native**: `[widescreen.cull] auto_backdrop` (config_loader + main_psx +
code_generator `detect_backdrop_windows()` + translate_instruction emit + extern).
- **interp** `dirty_ram_interp.c`: `ws_backdrop_site_kind(pc)` windowed+cached
scan, applied at exec_one top (gated on preload). NEEDED for the dev build
(overlays run interpreted).
- **sljit** `overlay_sljit.c`: PASS-1 fragment detection → `s_bd_kind[]` →
emit_one routes the move/addiu through psx_ws_backdrop_value.
- **overlay ABI**: `ws_backdrop_value` callback appended (overlay_api.h),
PSX_OVERLAY_ABI_VERSION 3→**4**, PSX_OVERLAY_CODEGEN_VER 1→**2**; wired in
overlay_loader.c + the compile_overlays.py DLL glue.
- **game.toml**: `auto_backdrop = true`.
### Verified
- Detector vs ground truth: flower-field 12 sites (incl the swapped-role window
DE0=END/DE8=START), village 8 sites (a2/a3 alloc, floor 1) — 0 false positives
over ~91k words. Main-EXE regen: 0 windows (generators are overlay-resident).
- gcc emit: synthetic recompile of the flower-field overlay (seed 0x80116800)
emits exactly 12 `psx_ws_backdrop_value()` calls at db0/db8/de0/de8/e24/e2c/
168e8/168f0/169a0/169a4/e64/e68, correct START/END roles, byte-identical at 4:3.
- recompiler + runtime + overlay-cache (cg2) all build clean; runtime boots (TCP
4470, ws mode=2).
### Remaining
- LIVE-SCENE verify (user navigates the flower-field/sky-platform scene in 16:9;
confirm right-margin void fills + 4:3 byte-identical; `wide_shot` to capture).
In the dev build the generator runs INTERPRETED → the interp hook applies it;
overlay_cache=true also self-heals a cg2 DLL with the rewrite once the scene is
captured + autocompiled.
- Merge `feat/ws-backdrop-coverage` → master (both repos), bump Tomba pin, push.
- sljit coverage is fragment-local (a window whose magic load falls in a different
fragment than its mult isn't detected on the sljit path — interp/gcc cover it).
---
# 2026-06-16 — ROOT CAUSE CORRECTED. The column-preload above was the WRONG LEVER.
Everything above (auto_backdrop column preload: force START→0 / END→extent to draw
the whole finite tile row) is **superseded as the fix for the 16:9 void.** It is
not wrong code, but it does not address the symptom. Live RE on the dwarf/cursed
flower-field scene, debug-instrumented, debunked the premise.
## DEBUNKED: the void is NOT a column-loading problem
Always-on `ws_backdrop_ring` on the live scene: the active flower-field generator
fires exactly ONE window (wcols=9, extent=41). Forcing whole-row (START→0,
END→clamped 40 — the entire 41-col row loaded every frame) and the cyan void
**still persists**. Loading more columns is irrelevant. The `ws_backdrop_margin`
knob (live column-widen: <0 whole-row, 0 off, >0 N-cols) confirms this: off vs
whole-row are pixel-identical on the void.
## ACTUAL ROOT CAUSE: native-wide 2D-backdrop POSITIONING (3 confirmations)
1. The flower-field is a **fixed-screen-position 2D tile field**: static GPU
primitive vertices (signed-11-bit SCREEN coords — read the same at cam 1585 AND
1550, they do NOT move with the camera), ~4:3 width (X extent ≈ -92..252 ≈
344px). The "scroll" swaps which columns fill the fixed slots (the camera
window), it does not move the slots.
2. Native-wide widens **3D via the GTE** (the un-squash generates wider geometry at
the source). The 2D backdrop **bypasses the GTE**, so it stays 4:3-width and
cannot span the 426px native-wide frame → the layer behind (cyan) shows on the
trailing margin. `psx_ws_backdrop_x` (the existing 2D-backdrop screen-X widen) is
SQUASH-only (`ws_active()`); in native-wide `gpu_state` shows `configured=0,
active=0` → it is a no-op.
3. The GL native-wide compositor mirrors each prim into the wider FBO by
**translation only** (`wide_dx() = g_wide_off - base_x`, gpu_gl_renderer.c). 3D
fills the FBO because it is pre-widened; the 2D backdrop is centred → margins.
## THE FIX — mechanism PROVEN, classification is the remaining work
**Scale the 2D-backdrop prims about screen-centre in the GL wide-mirror**: a
geometric scale in the vertex shader `x' = (x - u_xcenter)*u_xscale + u_xcenter`,
scale = g_wide_w/native_w ≈ 1.33. This stretches the layer — tiles widen AND
spread together, no inter-tile gaps. **PROVEN (user-confirmed): with the gate
disabled, the backdrop fully fills the 16:9 frame, void gone.** Shipped runtime-
tunable: `ws_backdrop_stretch [on] [pct] [thresh] [mode]` (live).
Implementation (gpu_gl_renderer.c, this branch, uncommitted→committed checkpoint):
u_xscale/u_xcenter uniforms in the geo+tex programs (default 1.0/0 = bit-identical
no-op) + `wide_set_bd_scale` in both wide-mirror passes; per-frame phase reset in
`gl_perf_present_enter` (NOT `present_vram` — that is the 4:3-fallback present; the
native-wide present is `gl_renderer_present_wide_fbo`, and the per-frame hook must
be in the common `gl_perf_present_enter` both call).
### The remaining problem: stretch ONLY the background
Stretching everything also stretches Tomba + HUD + 3D (user-rejected). Draw-order
"phase" heuristics FAILED because:
- the far-parallax backdrop (ocean/cloud/mountain, FUN_8004db3c, GTE-projected) is
WIDE and draws FIRST — and is ALREADY correctly widened by the 8C un-squash, so
it must NOT be re-stretched; the flower-field (2D, narrow) draws AFTER it; so
"first wide prim ends the phase" clears on the far-parallax before the flowers.
- Tomba/HUD draw last but are WITHIN 4:3 (narrow), so "narrow" includes them.
### BREAKTHROUGH — draw-time classification IS possible (the path forward)
The draw path HAS the per-prim **source address via `gp0_cmd_source_addr`** (gpu.c)
— that is how `ws_tagged_anchor` does the HUD pivot at draw time. So:
- Tomba/HUD/characters are **sprite-tagged** (`psx_ws_sprite_tag` stores
prim_ptr→anchor in `ws_tags`, keyed by `$a0 & 0x1FFFFC`). At draw time
`ws_tagged_anchor(gp0_cmd_source_addr)` matches them → foreground → DON'T stretch.
- The flower-field tiles live in the backdrop data structure `a1` (e.g.
0x801a603c: extent byte @+0, table of packet offsets @+4, packets at
a1+table[col]); their `gp0_cmd_source_addr` falls inside that structure's range
(capturable from the generator's a1 via the ring).
- **PROPOSED GATE** for `wide_set_bd_scale`: stretch a wide-mirror prim iff
native-wide AND NOT sprite-tagged (invert `ws_tagged_anchor` on
`gp0_cmd_source_addr`) AND narrow/not-GTE-wide. (Or match `gp0_cmd_source_addr`
against the known backdrop-structure address range.) This is the next concrete
step — wire `gp0_cmd_source_addr` into the GL wide-mirror gate. NOTE: this needs
the GL path to receive `gp0_cmd_source_addr` per prim (it is gpu.c state today).
## GENERATOR (RE'd via Ghidra `overlay_flowerfield.bin`)
0x80116808: extent s7=[a1+0] (byte); dispatch by backdrop type (jumptable
0x8011523c, 5 types); each type computes a column window then `j` to the SHARED
emit loop 0x80116e6c, which clamps START<0→0 / END≥extent→extent-1 and stores
packet pointers (a1+table[col]) into the OT object (a0, count byte @a0+3). One
window per call. No code xref / no caller in RAM (main-EXE tail-dispatch; not a
function pointer in the EXE/overlay binaries either).
## FREEZE in this area — INDEPENDENT of widescreen (dedicated read-only RE pass)
The recurring fatal wedge is the pre-existing Tomba **wild-call / runaway-recursion
family** (seesaw / Bug-D), NOT a kernel livelock (the 0x2104 thread thrash + epc
0x80000048 are the HEALTHY VSync scheduler; I was wrong to suspect a livelock).
Chain: seesaw entity loop func_8001DFD4 → interaction-queue dispatcher FUN_80054D60
→ a WILD jal in overlay handler 0x80123EE0. The Bug-D contract contains it but
never resolves (bail_resolved=0, ~93K flattens) → infinite per-frame storm →
eventually exhausts a fiber stack (report "native stack guard — runaway recursion",
recursion_func 0x8004DEE0) OR func_80056E08 derefs a garbage interaction-queue slot
→ MMIO READ8 @ 0x1F803C00 (the LOAD-MENU crash). Root fix = Beetle-oracle
data-divergence hunt for the corruptor; containment = skip the dead entity in the
flatten path (full_function_emitter.cpp). See TombaRecomp/ISSUES.md.
## DEBUG TOOLING SHIPPED (runtime-only, this branch)
- `overlay_cache=false` (all overlays interpreted; no native-DLL blind spots) —
game.toml DEBUG toggle (revert before merge).
- `ws_backdrop_ring` — always-on rewrite ring (pc, kind, wcols, orig, final,
extent, camx, count, a1/dl).
- `ws_backdrop_margin [m]` — live column-widen (proven irrelevant to the void).
- `ws_backdrop_stretch [on] [pct] [thresh] [mode]` — live 2D-stretch tuning + a
per-frame dbg block (applied/prims/clearx/wide_cur/base/wide_w/off).
- Keyboard ALWAYS drives player 1 (OR'd with any controller; main.cpp) — debug
convenience, unconditional.
# 2026-06-17 — CONTENT-MATCH DISPROVEN; writer chain mapped; SOURCE-SIDE chosen
The "BREAKTHROUGH — draw-time classification" / content-match plan above is the
THIRD dead end. Live RE (GP0-ring tools in TombaRecomp/tools/an_*.py):
- The cmd-0x25 flower template (a1=0x801a603c, generator 0x80116808) is TRANSFORMED
before drawing: template clut 0x1c91/0x4bde, tpage 0x00a4 — NONE of those appear
in any drawn prim (drawn flat-prim cluts all 0x7xxx); ~110KB of active template
blobs collapse to a ~14KB drawn OT. No stable per-prim fingerprint exists.
- Flower prims are REBUILT into the OT each frame (DMA src = OT 0x0b3xxx, disjoint
from struct 0x1a6xxx) → the committed address-range gate can never match (applied=0).
- The early-drawn 0x65 sprite GRID (6 cols x7, X=-2..318 step-64, cluts 780f/784f/798f,
drawn before any 3D) is the leading void-layer suspect. Its XY is written via `sh`
(X=0x13e=318) by SHARED helper 0x8005dfd8 (caller ra=0x8004e188), from a main-EXE
builder at 0x8004e120-0x8004e188 that ALSO sprite-tags via 0x8005e08c → those
grid sprites are SPRITE-TAGGED. So the fix likely belongs in the sprite-tag /
native-wide path, NOT x_sites. (psx_ws_backdrop_x is squash-only; needs a
native-wide STRETCH branch regardless.)
- TOOLING NOTE: wtrace `pc` is the STALE last-interp pc for native writes (codegen
emits no g_debug_last_store_pc; only the interp sets it → all native OT writes
read pc=0x80116f20). Trust the `ra` field (= jal+8). FUN_80027600 (GTE far-parallax
emitter, the 8C un-squash target) writes polygons into the same OT buffer, so
range-based wtrace is contaminated — use single-word traces (an_writer2.py).
- OPEN/DO-FIRST: VERIFY which drawn prims actually constitute the void (build a
live prim<->pixel correlation: stretch/tint only a chosen {op,clut} or OT-range
set, iterate vs wide_shot) BEFORE picking the source site. Then scale that layer's
X at the source. Details + full reasoning in memory ws_backdrop_preload.md
(2026-06-17 section).