Files
psxrecomp/docs/internal/BACKDROP_PRELOAD.md
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

21 KiB
Raw Permalink Blame History

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//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).