Make the repo approachable for outside contributors and an IDE integrator:
- New contributor docs:
- docs/EXECUTION_MODEL.md — semi-technical tour of the static / native-overlay
/ interpreter execution model and how coverage gaps get plugged.
- docs/ARCHITECTURE.md — component-level view (recompiler, runtime, LLE+HLE
BIOS, overlay backend static->gcc->tcc, renderers, oracle model).
- docs/BUILDING.md — accurate dependency table, per-platform prereqs, build
steps, and the submodule + local-junction framework-linking setup.
- CONTRIBUTING.md — distilled core rules, verification expectations, PR flow.
- Declutter root: move ~14 internal dev-logs into docs/internal/ (history
preserved via rename) with an index README; repoint CLAUDE.md references.
- README: correct build recipe, LLE+HLE + overlay-backend summary, links to the
new docs.
- code_provider.c: update the stale backend comment to the real static->gcc->tcc
resolution (dead SLJIT.md reference removed).
- config_schema.md: drop the obsolete "once Phase A2/A3 ships --config" note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
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):
0x176camX 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 the0x176camera 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,Nend-delta with small N, immediately after themove 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)
- START finalize
move rStart, rQ→ forcerStart = 0when widescreen active. The existing low clamp keeps it ≥ floor. - END delta
addiu rEnd, rStart, N→ forceN = 0x100(≥ any 8-bit table length) when widescreen active. The existing high clamp pinsrEndtoextent-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.pyalready forwards--ws-config(game.toml), soauto_backdropreaches it. BumpPSX_OVERLAY_CODEGEN_VERand 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. 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 agte.h, so no include-dir merge). Gate permult: bounded BACKWARD scan for the exact magic (lui 0x6666 + ori 0x6667) into one operand + alh/lhu …,0x176into 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 theaddiu(negative offset) is START and themoveis 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, likepsx_ws_cull_sltiu. - gcc/native:
[widescreen.cull] auto_backdrop(config_loader + main_psx + code_generatordetect_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_valuecallback 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_shotto 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)
- 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.
- 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-widegpu_stateshowsconfigured=0, active=0→ it is a no-op. - 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_tagstores prim_ptr→anchor inws_tags, keyed by$a0 & 0x1FFFFC). At draw timews_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]); theirgp0_cmd_source_addrfalls 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 (invertws_tagged_anchorongp0_cmd_source_addr) AND narrow/not-GTE-wide. (Or matchgp0_cmd_source_addragainst the known backdrop-structure address range.) This is the next concrete step — wiregp0_cmd_source_addrinto the GL wide-mirror gate. NOTE: this needs the GL path to receivegp0_cmd_source_addrper 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
pcis 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 therafield (= 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).