The sljit Tier-2 in-process JIT was replaced by the tcc tier inc2a3f6ad(2026-06-25) and removed outright in5b7e69b4(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>
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. [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. Seeruntime/src/overlay_backend.c.] For full coverage likeauto_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).