Files
psxrecomp/docs/internal/BACKDROP_PRELOAD.md
T
Matthew Stanley 55cec09acd docs: retire stale sljit references, attribute TinyCC
The sljit Tier-2 in-process JIT was replaced by the tcc tier in c2a3f6ad
(2026-06-25) and removed outright in 5b7e69b4 (2026-07-15). That removal
reworded the C-level comments but touched no markdown, so the prose still
described sljit as a live, default-on backend.

Backends today are static -> gcc -> tcc: gcc is the development default when
it is on PATH, tcc is the toolchain-free path for players. Both are external
compilers invoked as a subprocess that produce a DLL; neither is an in-process
JIT. docs/ARCHITECTURE.md, docs/BUILDING.md, docs/EXECUTION_MODEL.md,
docs/COMPILING_OVERLAYS.md and README.md already said this correctly and are
unchanged here.

- THIRD_PARTY_ATTRIBUTION.md: the sljit entry claimed code vendored at
  lib/sljit/ and compiled into the runtime. That directory does not exist.
  Replaced with a TinyCC entry, which was missing entirely despite tcc being
  the compiler intended to reach end users.
- docs/internal/SLJIT_PERSIST_CACHE.md: deleted. It documented the persisted
  shard cache of a subsystem that no longer exists, as "implemented" and
  "default-on". docs/internal/README.md updated to point at the current model
  instead of a dead link.
- docs/ASYNC_OVERLAY_COMPILE.md: superseded-in-part header. Deliberately NOT
  rewritten sljit->tcc: the passages about serializing .sljit blobs,
  overlay_sljit_deserialize and an in-memory JIT registry describe a mechanism
  tcc never used, so renaming it would trade one false claim for another. The
  async worker/file-handoff design and the CPS work remain accurate.
- docs/OVERLAY_CACHE_V2.md: sljit dropped from the dispatch priority chain.
- ISSUES.md: Issue #7 closed as superseded — the backend it reported against
  was deleted rather than fixed.
- docs/internal/COSIM_ORACLE.md: sljit_helpers left CPUState with the tier.
- Dated session logs (RECURSION_BUG.md 25.5, BACKDROP_PRELOAD.md,
  ecosystem-watch.md) keep their narrative and get a pointer to the removal.

One thing this does not fix: docs assert the tcc toolchain is "downloaded at
release-packaging time and bundled beside the game exe". runtime/src/main.cpp
does look for <exe_dir>/overlay_toolchain/, but no script in this repository
creates it. The attribution entry now states only what the code does and notes
that if packaging supplies the bundle, it happens outside this repo and must
carry the TinyCC notice. Flagged rather than papered over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:16:34 -07:00

21 KiB
Raw 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).