From 9473530be408d4f9e26397a50bdf7924b3e37788 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 13:51:44 -0700 Subject: [PATCH 01/17] Resolve X2/X3 margin tiles from exact world sources at raster phase --- runner/runner.cmake | 3 +- runner/src/snes/mmx23_background.c | 193 +++++++++++++++++++++++++++++ runner/src/snes/mmx23_background.h | 9 ++ runner/src/snes/ws_shadow.c | 45 ++++++- runner/src/snes/ws_shadow.h | 9 ++ tests/ppu/test_world_provider.py | 28 +++++ tests/ppu/ws_world_provider_test.c | 51 ++++++++ 7 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 runner/src/snes/mmx23_background.c create mode 100644 runner/src/snes/mmx23_background.h create mode 100644 tests/ppu/test_world_provider.py create mode 100644 tests/ppu/ws_world_provider_test.c diff --git a/runner/runner.cmake b/runner/runner.cmake index 074e2280..52bc9559 100644 --- a/runner/runner.cmake +++ b/runner/runner.cmake @@ -299,7 +299,8 @@ set(SNESRECOMP_RUNNER_SOURCES # it, authored from this core's observed behavior. # list(APPEND SNESRECOMP_RUNNER_SOURCES - ${SNESRECOMP_RUNNER_ROOT}/src/snes/cx4.c) + ${SNESRECOMP_RUNNER_ROOT}/src/snes/cx4.c + ${SNESRECOMP_RUNNER_ROOT}/src/snes/mmx23_background.c) message(STATUS "Cx4: instruction-level HG51B S169 core (ares, ISC)") # ── Windows: UTF-8 process code page ────────────────────────────────────── diff --git a/runner/src/snes/mmx23_background.c b/runner/src/snes/mmx23_background.c new file mode 100644 index 00000000..c76d1765 --- /dev/null +++ b/runner/src/snes/mmx23_background.c @@ -0,0 +1,193 @@ +/* X2/X3 share the retail two-layer map format. Native view is always VRAM; + * only self-validated margin lookups use these retained WRAM/ROM sources. */ +#include "mmx23_background.h" +#include +#include +#include +#include +#include "cart.h" +#include "ppu.h" +#include "snes.h" +#include "ws_shadow.h" +static Snes *s_snes; +static Ppu *s_ppu; +static uint8_t *s_ram; +/* A frame-local memo avoids decoding the same metatile on all eight raster + * lines. Its size is independent of world height; collisions re-resolve. */ +static uint32_t s_tile_keys[4096]; +static uint16_t s_tile_values[4096]; +typedef struct MmxBgStream { + uint16_t layoutBase; /* screen-id layout window, bank $7E */ + uint16_t screenDefs; /* 16x16 block-id maps, 512B/screen, bank $7E */ + uint16_t mtPtrAddr; /* WRAM address of 24-bit metatile table ptr */ + uint16_t worldXAddr; /* stream-struct world anchor (16-bit) */ + uint16_t worldYAddr; + uint8_t bgsc; /* expected BGnSC value: map base + 64x32 */ + uint16_t mapBaseWord; /* VRAM word base, for native self-validation */ +} MmxBgStream; + +static const MmxBgStream kBgStreams[2] = { + {0xE800, 0x2800, 0x09C5, 0x1E5D, 0x1E60, 0x51, 0x5000}, /* BG1 */ + {0xEC00, 0xA600, 0x09C8, 0x1E9D, 0x1EA0, 0x59, 0x5800}, /* BG2 */ +}; + +static uint16_t wram16(uint32_t addr) { + return (uint16_t)(s_ram[addr] | (s_ram[addr + 1] << 8)); +} + +/* Exact tilemap entry for world pixel (px, py), via the game's own walk. */ +static uint16_t world_tile(const MmxBgStream *s, uint32_t px, + uint32_t py) { + uint32_t li = (((py >> 8) & 0x1F) << 5) | ((px >> 8) & 0x1F); + uint8_t sid = s_ram[s->layoutBase + li]; + uint16_t ba = (uint16_t)(s->screenDefs + sid * 512u + + ((py >> 4) & 0xF) * 0x20u + ((px >> 4) & 0xF) * 2u); + uint16_t block = (uint16_t)(s_ram[ba] | (s_ram[(uint16_t)(ba + 1)] << 8)); + uint16_t mt_addr = wram16(s->mtPtrAddr); + uint8_t mt_bank = s_ram[s->mtPtrAddr + 2]; + /* 16-bit wrap with a fixed bank replicates the guest's LDA [$1C] walk + * ($00:B4EE), whose pointer arithmetic never carries into the bank. */ + uint16_t a = (uint16_t)(mt_addr + block * 8u + (((py >> 3) & 1u) << 2) + + (((px >> 3) & 1u) << 1)); + return (uint16_t)(cart_read(s_snes->cart, mt_bank, a) | + (cart_read(s_snes->cart, mt_bank, (uint16_t)(a + 1)) + << 8)); +} + +/* VRAM word address the 64x32 map stores world pixel (px, py) at. */ +static uint16_t vram_word(const MmxBgStream *s, uint32_t px, + uint32_t py) { + uint32_t tx = px >> 3, ty = py >> 3; + return (uint16_t)(s->mapBaseWord + ((tx >> 5) & 1u) * 0x400u + + (ty & 0x1F) * 0x20u + (tx & 0x1F)); +} + +/* The resolver must reproduce the live native view to earn the gutters. */ +static bool stream_valid(const MmxBgStream *s, int32_t wx, int32_t wy) { + if (wx < 0 || wy < 0) + return false; + /* Boot/menu frames can already have the gameplay BG mode/map bases while + * the stream-source cluster is still zero. Reject before cart_read so an + * uninitialized pointer cannot create a false off-rails diagnostic. */ + if (wram16(s->mtPtrAddr) < 0x8000) + return false; + int miss = 0, modal = 0; + uint16_t modal_tile = 0; + uint16_t got_v[12]; + for (int i = 0; i < 12; i++) { + uint32_t px = (uint32_t)wx + 10u + (uint32_t)i * 20u; /* 10..230 */ + uint32_t py = (uint32_t)wy + 12u + (uint32_t)(i % 6) * 36u; /* 12..192 */ + uint16_t want = world_tile(s, px, py); + uint16_t got = s_ppu->vram[vram_word(s, px, py) & 0x7FFF]; + got_v[i] = got; + if (want != got) + miss++; + } + /* One mismatch tolerated: a single in-flight column upload must not + * flicker the margins off for a frame. Two or more = not our scene. */ + if (miss > 1) + return false; + /* Diversity: a near-uniform native sample proves nothing — some scenes + * repurpose BG2 as a mostly-transparent OBJECT layer (X-Hunter tower + * lifts live in the BG2 map and move by scroll). There the provider's + * "level data" is all blank, matches trivially, and would erase the + * object in the gutters (owner-reported half-culled platforms). Require + * real structure in the view; otherwise authentic map wrap serves the + * object correctly. */ + for (int i = 0; i < 12; i++) { + int same = 0; + for (int j = 0; j < 12; j++) + same += (got_v[j] == got_v[i]); + if (same > modal) { + modal = same; + modal_tile = got_v[i]; + } + } + if (modal <= 9) + return true; + + /* + * A sparse level screen can legitimately make the quick sample almost + * uniform. Slot 0's vertical fall is mostly empty until the terrain scrolls + * into view, so the old diversity gate disabled exact gutters and exposed + * wrapped stale rows at the widescreen edges. + * + * Preserve the object-layer safeguard by looking for positive evidence: + * scan the native view for six source tiles that differ from the modal + * entry and require each one to reproduce VRAM. A repurposed object layer + * whose level source is blank finds no proof; one whose live objects + * disagree with that source fails the comparisons. + */ + int proof = 0, proof_miss = 0; + for (uint32_t y = 4; y < 224; y += 8) { + for (uint32_t x = 4; x < 256; x += 8) { + uint32_t px = (uint32_t)wx + x; + uint32_t py = (uint32_t)wy + y; + uint16_t want = world_tile(s, px, py); + if (want == modal_tile) + continue; + uint16_t got = + s_ppu->vram[vram_word(s, px, py) & 0x7FFF]; + if (want == got) { + if (++proof >= 6) + return true; + } else if (++proof_miss > 1) { + return false; + } + } + } + return false; +} + + +static bool world_provider(void *context, uint32_t tx, uint32_t ty, uint16_t *entry) { + const MmxBgStream *stream = context; + if (tx >= 1024 || ty >= 1024) return false; + uint32_t key = tx | (ty << 10) | ((stream == &kBgStreams[1]) << 20); + unsigned cache = (key * 2654435761u) >> 20; + if (s_tile_keys[cache] == key + 1) { + *entry = s_tile_values[cache]; return true; + } + uint32_t x = tx * 8, y = ty * 8; + *entry = world_tile(stream, x, y); + s_tile_keys[cache] = key + 1; + s_tile_values[cache] = *entry; + return true; +} + +void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { + if (!ppu) return; + static int on = -1, debug = -1; + if (on < 0) { + const char *e=getenv("SNESRECOMP_WS_BG_MARGINS"); on=!e || e[0]!='0'; + e=getenv("SNESRECOMP_WS_BG_MARGINS_DEBUG"); debug=e && e[0]!='0'; + } + s_snes=snes; s_ppu=ppu; s_ram=ram; + bool scene = on && enabled && snes && ppu && ram && + (ppu->bgmode & 0x37)==1 && ppu->bgXsc[0]==0x51 && ppu->bgXsc[1]==0x59; + bool any=false; + memset(s_tile_keys,0,sizeof(s_tile_keys)); + if (scene) for (unsigned layer=0; layer<2; ++layer) { + const MmxBgStream *stream=&kBgStreams[layer]; + int32_t wx=wram16(stream->worldXAddr), wy=wram16(stream->worldYAddr); + int32_t dx=(ppu->hScroll[layer]-wx)&1023, dy=(ppu->vScroll[layer]-wy)&1023; + if (dx>=512) dx-=1024; + if (dy>=512) dy-=1024; + wx+=dx; wy+=dy; + if (!stream_valid(stream,wx,wy)) continue; + WsShadowSetWorld(layer,(uint32_t)wx,(uint32_t)wy); + WsShadowSetWorldProvider(layer,world_provider,(void *)stream); + WsShadowSetBlankTile(layer,-1); + WsShadowSetRespectGameWrites(layer,60); + any=true; + } + static bool was_active; + if (!any && was_active) WsShadowReset(); + was_active=any; + WsShadowFrame(ppu); + if (debug) { + static unsigned calls; + if (!(calls++%120)) fprintf(stderr,"[mmx23_bg] scene=%u layers=%u/%u\n", + scene,WsShadowLayerActive(0),WsShadowLayerActive(1)); + } +} diff --git a/runner/src/snes/mmx23_background.h b/runner/src/snes/mmx23_background.h new file mode 100644 index 00000000..0eb33757 --- /dev/null +++ b/runner/src/snes/mmx23_background.h @@ -0,0 +1,9 @@ +#ifndef SNES_MMX23_BACKGROUND_H +#define SNES_MMX23_BACKGROUND_H +#include +#include +struct Snes; +struct Ppu; +void Mmx23ConfigureWsBgMargins(struct Snes *snes, struct Ppu *ppu, + uint8_t *ram, bool enabled); +#endif diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 2e115529..7440a48a 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -21,12 +21,15 @@ typedef struct WsShadowLayer { bool wide; bool fold; /* periodic fold enabled (composes with world history) */ bool worldSet; /* WsShadowSetWorld called this frame */ + bool haveWorldAnchor; int dir; /* last nonzero worldX motion: +1 right, -1 left */ uint8_t tileShift; /* 3 = 8x8 map entries, 4 = 16x16 big tiles */ uint32_t worldX; uint32_t worldY; uint32_t scrollX; uint32_t scrollY; + WsShadowWorldProvider provider; + void *providerContext; uint16_t mapBaseWord; uint16_t *entries; uint8_t *valid; @@ -174,6 +177,10 @@ void WsShadowReset(void) { layer->active = false; layer->fold = false; layer->worldSet = false; + layer->haveWorldAnchor = false; + layer->dir = 0; + layer->provider = NULL; + layer->providerContext = NULL; layer->haveLastOrigin = false; layer->haveRetainMapBase = false; layer->havePrevLive = false; @@ -216,14 +223,24 @@ void WsShadowSetWorld(int layerIndex, uint32_t worldX, uint32_t worldY) { } layer->registered = true; layer->worldSet = true; - if (worldX != layer->worldX) + layer->provider = NULL; + layer->providerContext = NULL; + if (layer->haveWorldAnchor && worldX != layer->worldX) layer->dir = ((int32_t)(worldX - layer->worldX) > 0) ? 1 : -1; + layer->haveWorldAnchor = true; layer->worldX = worldX; layer->worldY = worldY; layer->scrollX = worldX; layer->scrollY = worldY; } +void WsShadowSetWorldProvider(int layerIndex, WsShadowWorldProvider provider, + void *context) { + if (layerIndex < 0 || layerIndex >= kLayers) return; + s_layers[layerIndex].provider = provider; + s_layers[layerIndex].providerContext = context; +} + void WsShadowSetScroll(int layerIndex, uint32_t scrollX, uint32_t scrollY) { if (layerIndex < 0 || layerIndex >= kLayers) return; @@ -1307,10 +1324,32 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, (layer->scrollY & ((1u << shift) - 1))) : (int32_t)(layer->worldY + ((wrappedY - layer->scrollY) & 0x3ff)); + bool exact = false; + uint16_t exactEntry = 0; + if (layer->provider && !layer->retainHistory) { + /* One tile can straddle the native edge under fine scroll. Its native + * pixels remain authoritative even during an in-flight column upload. */ + if (screenX < 256 && screenX + tile_pixels > 0) return realTile; + int32_t dx = (hScroll - layer->scrollX) & 0x3ff; + int32_t dy = (wrappedY - layer->scrollY) & 0x3ff; + if (dx >= 512) dx -= 1024; + if (dy >= 512) dy -= 1024; + worldX += dx; + worldY = (int32_t)layer->worldY + dy; + if (worldX >= 0 && worldY >= 0) { + exact = layer->provider(layer->providerContext, + (uint32_t)worldX >> shift, + (uint32_t)worldY >> shift, &exactEntry); + if (exact) WsShadowForceTile(layerIndex, (uint32_t)worldX >> shift, + (uint32_t)worldY >> shift, exactEntry); + } + } if (layer->entries && worldX >= 0 && worldY >= 0) { uint16_t entry; - const bool hit = GetEntry(layer, (uint32_t)worldX >> shift, - (uint32_t)worldY >> shift, &entry); + bool hit = GetEntry(layer, (uint32_t)worldX >> shift, + (uint32_t)worldY >> shift, &entry); + /* An exact source is not constrained by the bounded history store. */ + if (!hit && exact) { entry = exactEntry; hit = true; } /* Always-on margin hit/miss accounting, split by side. Cheap counters, no * arming: without them "the gutter looks the same" is indistinguishable * from "history was never consulted" from "history was consulted and diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index 5b44a4db..1afa250c 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -21,6 +21,15 @@ void WsShadowReset(void); * call WsShadowSetScroll with the PPU buffer scroll. */ void WsShadowSetWorld(int layer, uint32_t worldX, uint32_t worldY); +/* Exact, uncached map source for a streaming title. Register after SetWorld + * each frame. Queries use the rendered scanline's scroll phase and signed + * 10-bit deltas, and cover world positions beyond the history cache bounds. + * Native pixels retain VRAM; recent explicit game writes keep precedence. */ +typedef bool (*WsShadowWorldProvider)(void *context, uint32_t worldTileX, + uint32_t worldTileY, uint16_t *entry); +void WsShadowSetWorldProvider(int layer, WsShadowWorldProvider provider, + void *context); + /* PPU scroll used to address the streaming VRAM tilemap window and to convert * scanline-wrapped Y back to world Y. Defaults to the SetWorld values. */ void WsShadowSetScroll(int layer, uint32_t scrollX, uint32_t scrollY); diff --git a/tests/ppu/test_world_provider.py b/tests/ppu/test_world_provider.py new file mode 100644 index 00000000..a4d8d6e2 --- /dev/null +++ b/tests/ppu/test_world_provider.py @@ -0,0 +1,28 @@ +"""Compile the ROM-free production margin lookup on Windows and Unix.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +def test_world_provider(): + cc = os.environ.get('CC') or shutil.which('cc') or shutil.which('gcc') + if not cc and os.name == 'nt': + mingw = Path('C:/msys64/mingw64/bin/gcc.exe') + if mingw.is_file(): + cc = str(mingw) + if not cc: + pytest.skip('C11 compiler required') + with tempfile.TemporaryDirectory(prefix='ws-provider-') as tmp: + exe = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test') + subprocess.run([cc, '-std=c11', '-O2', '-Irunner/src', + 'tests/ppu/ws_world_provider_test.c', + 'runner/src/snes/ws_shadow.c', '-o', str(exe)], + cwd=ROOT, check=True) + subprocess.run([str(exe)], check=True) + +if __name__ == '__main__': + test_world_provider() diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c new file mode 100644 index 00000000..2cdf5816 --- /dev/null +++ b/tests/ppu/ws_world_provider_test.c @@ -0,0 +1,51 @@ +/* Production margin lookup: high world Y, raster phase, native identity and + * dynamic-object precedence. No game ROM or generated code is required. */ +#include +#include +#include +#include "snes/ppu.h" +#include "snes/ws_shadow.h" + +int snes_frame_counter; +static Ppu p; +static unsigned calls, last_x, last_y; +static bool provider(void *context, uint32_t x, uint32_t y, uint16_t *entry) { + assert(context == &p); + ++calls; last_x=x; last_y=y; + *entry=(uint16_t)(0x2000 | ((x + y * 3) & 1023)); + return true; +} +static void setup(unsigned x, unsigned y) { + WsShadowReset(); memset(&p,0,sizeof(p)); + p.bgXsc[0]=0x51; + WsShadowSetWorld(0,x,y); + WsShadowSetWorldProvider(0,provider,&p); + WsShadowSetRespectGameWrites(0,60); + WsShadowFrame(&p); calls=0; +} +int main(void) { + setup(0x1c00,0x1300); + assert(WsShadowTile(0,100,0x300,0,0x5000,0x1234)==0x1234 && calls==0); + assert(WsShadowTile(0,255,0x300,0,0x5000,0x5678)==0x5678 && calls==0); + assert(WsShadowTile(0,-7,0x300,0,0x5000,0x789a)==0x789a && calls==0); + uint16_t got=WsShadowTile(0,256,0x308,0,0x5400,0xdead); + assert(calls==1 && last_x==0x1d00/8 && last_y==0x1308/8); + assert(got==(uint16_t)(0x2000|((last_x+last_y*3)&1023))); + (void)WsShadowTile(0,-40,0x2e8,128,0x5000,0xdead); + assert(last_x==(0x1c00-40+128)/8 && last_y==(0x1300-24)/8); + (void)WsShadowTile(0,256,0x308,896,0x5000,0xdead); + assert(last_x==(0x1d00-128)/8); + setup(0,0x200); snes_frame_counter=10; + WsShadowOnVramWrite(0x5400,0xabcd); + assert(WsShadowTile(0,256,0x200,0,0x5400,0)==0xabcd); + snes_frame_counter=71; + assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); + WsShadowReset(); + assert(WsShadowTile(0,256,0x200,0,0x5400,0x4567)==0x4567); + /* Provider registration is frame scoped; other callers keep old behavior. */ + setup(0,0x200); WsShadowSetWorld(0,0,0x200); WsShadowFrame(&p); calls=0; + (void)WsShadowTile(0,256,0x200,0,0x5400,0); + assert(calls==0); + puts("exact world provider: high-Y, signed raster phase, native pixels, dynamic writes and lifecycle passed"); + return 0; +} From 6f83dc1ff014fa96d021bc06578ccbeaa3376099 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 13:58:07 -0700 Subject: [PATCH 02/17] Keep dynamic background writes addressable across the full X2/X3 map height --- runner/src/snes/ws_shadow.h | 4 +++- tests/ppu/ws_world_provider_test.c | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index 1afa250c..ab6fe99c 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -10,7 +10,9 @@ // layers, and both 64-wide and streamed 32-wide tilemaps. enum { kWsShadowXTiles = 4096, - kWsShadowYTiles = 512, + /* X2/X3 layouts span 32 screens vertically. Keep dynamic game writes + * addressable through that full height, including the rocket hangar. */ + kWsShadowYTiles = 1024, }; void WsShadowReset(void); diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index 2cdf5816..481bc346 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -40,6 +40,11 @@ int main(void) { assert(WsShadowTile(0,256,0x200,0,0x5400,0)==0xabcd); snes_frame_counter=71; assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); + setup(0,0x1300); snes_frame_counter=80; + WsShadowOnVramWrite(0x5400,0xbcde); + assert(WsShadowTile(0,256,0x300,0,0x5400,0)==0xbcde); + snes_frame_counter=141; + assert(WsShadowTile(0,256,0x300,0,0x5400,0)!=0xbcde); WsShadowReset(); assert(WsShadowTile(0,256,0x200,0,0x5400,0x4567)==0x4567); /* Provider registration is frame scoped; other callers keep old behavior. */ From 0c86f5f2205141830f570768f5ebe12c1ecac290 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 15:25:58 -0700 Subject: [PATCH 03/17] Apply X1 single-face boss door presentation to X2 and X3 --- .../validation/mmx23-door-faces-2026-10-26.md | 33 +++++++++ runner/src/snes/mmx23_background.c | 68 ++++++++++++++++--- runner/src/snes/mmx_door.h | 25 +++++++ runner/src/snes/ws_shadow.c | 18 +++-- runner/src/snes/ws_shadow.h | 17 +++-- tests/ppu/ws_world_provider_test.c | 32 ++++++++- 6 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 docs/validation/mmx23-door-faces-2026-10-26.md create mode 100644 runner/src/snes/mmx_door.h diff --git a/docs/validation/mmx23-door-faces-2026-10-26.md b/docs/validation/mmx23-door-faces-2026-10-26.md new file mode 100644 index 00000000..a35fad72 --- /dev/null +++ b/docs/validation/mmx23-door-faces-2026-10-26.md @@ -0,0 +1,33 @@ +# X2/X3 door presentation + +X1 already selected the boss-door column facing the camera's current room. +Port that structural rule to the shared X2/X3 exact background source. The +outward-facing duplicate samples the adjoining wall. Require all three +mirrored metatiles and distinct cap/body characters; X2's symmetric wall +decoration is a negative fixture, not a second door style. + +The world provider can explicitly return a presentation override. Only BG1 +opts into native-region overrides. Ordinary exact results preserve native +VRAM and recent game writes; a hidden door face must also suppress recent +uploads of that face during animation. Other world-provider consumers retain +their existing behavior. + +Validation in the paired X2/X3 trace builds: + +- Production C tests cover measured X2/X3 door words, rejection of the similar + wall, native pixel identity and explicit override precedence over game writes. +- X2 Serges approach and interior show one face per doorway on both sides. +- X3's 400-frame entry/open/cross/close sequence was sampled every four frames + against a simultaneous native-width replay. Excluding relocated HUD pixels + and precisely the hidden 16-pixel face, all 100 frames match: zero other + differing pixels, including the retained animated door face. +- Continuous and save/load branches match WRAM and every output pixel through + the X2 door crossing and X3 crossing/intro. The title load callbacks reset + unsaved presentation history rather than retaining the abandoned timeline. +- X2's muted, unthrottled software/debug build advances about 227 frames/sec + in the measured doorway scene. This measures simulation headroom, not a + display frame-time benchmark. + +Private ROM/state evidence: `G:/Temp/Codex/mmx23-trigger-audit-20261026/feedback3`. +The broader audits and owner acceptance remain open. This does not qualify the +previously reported X2 rocket margins or Serges entrance artwork. diff --git a/runner/src/snes/mmx23_background.c b/runner/src/snes/mmx23_background.c index c76d1765..8a74a098 100644 --- a/runner/src/snes/mmx23_background.c +++ b/runner/src/snes/mmx23_background.c @@ -1,5 +1,5 @@ -/* X2/X3 share the retail two-layer map format. Native view is always VRAM; - * only self-validated margin lookups use these retained WRAM/ROM sources. */ +/* X2/X3 share the retail two-layer map format. Native view uses VRAM except + * for X1's hidden door-face policy; self-validated gutters use WRAM/ROM. */ #include "mmx23_background.h" #include #include @@ -9,6 +9,7 @@ #include "ppu.h" #include "snes.h" #include "ws_shadow.h" +#include "mmx_door.h" static Snes *s_snes; static Ppu *s_ppu; static uint8_t *s_ram; @@ -16,6 +17,8 @@ static uint8_t *s_ram; * lines. Its size is independent of world height; collisions re-resolve. */ static uint32_t s_tile_keys[4096]; static uint16_t s_tile_values[4096]; +static uint32_t s_door_keys[4096]; +static uint8_t s_door_values[4096]; typedef struct MmxBgStream { uint16_t layoutBase; /* screen-id layout window, bank $7E */ uint16_t screenDefs; /* 16x16 block-id maps, 512B/screen, bank $7E */ @@ -140,19 +143,63 @@ static bool stream_valid(const MmxBgStream *s, int32_t wx, int32_t wy) { } -static bool world_provider(void *context, uint32_t tx, uint32_t ty, uint16_t *entry) { - const MmxBgStream *stream = context; - if (tx >= 1024 || ty >= 1024) return false; +static uint16_t source_tile(const MmxBgStream *stream, uint32_t tx, uint32_t ty) { uint32_t key = tx | (ty << 10) | ((stream == &kBgStreams[1]) << 20); unsigned cache = (key * 2654435761u) >> 20; if (s_tile_keys[cache] == key + 1) { - *entry = s_tile_values[cache]; return true; + return s_tile_values[cache]; } uint32_t x = tx * 8, y = ty * 8; - *entry = world_tile(stream, x, y); + uint16_t entry = world_tile(stream, x, y); s_tile_keys[cache] = key + 1; - s_tile_values[cache] = *entry; - return true; + s_tile_values[cache] = entry; + return entry; +} + +static bool door_body(int x, int y) { + if (x < 0 || x >= 8192 || y < 0 || y >= 8192) return false; + unsigned tx = (unsigned)x / 16, ty = (unsigned)y / 16; + uint32_t key = tx | (ty << 9); + unsigned cache = (key * 2654435761u) >> 20; + if (s_door_keys[cache] == key + 1) return s_door_values[cache]; + bool found = false; + for (int row = 0; row < 3 && !found; ++row) { + int top = (int)ty - row; + if (top < 0 || top + 2 >= 512) continue; + uint16_t words[3][4]; + for (int r = 0; r < 3; ++r) + for (int q = 0; q < 4; ++q) + words[r][q] = source_tile(&kBgStreams[0], tx * 2 + (q & 1), + (top + r) * 2 + (q >> 1)); + found = MmxDoorBody(words); + } + s_door_keys[cache] = key + 1; + s_door_values[cache] = found; + return found; +} + +static WsShadowSource world_provider(void *context, uint32_t tx, uint32_t ty, + uint16_t *entry) { + const MmxBgStream *stream = context; + if (tx >= 1024 || ty >= 1024) return kWsShadowMiss; + int x = (int)tx * 8, y = (int)ty * 8; + if (stream == &kBgStreams[0] && door_body(x, y)) { + bool left = door_body(x - 16, y), right = door_body(x + 16, y); + if (left || right) { + int boundary = (x & ~15) + (right ? 16 : 0); + bool view_left = wram16(stream->worldXAddr) + 128 < boundary; + /* Retain the face looking into the current room. The outward-facing + * face samples the wall beyond it, exactly as in X1's renderer. */ + if (view_left && left) tx += 2; + else if (!view_left && right) tx -= 2; + else { *entry = source_tile(stream, tx, ty); return kWsShadowExact; } + if (tx >= 1024) return kWsShadowMiss; + *entry = source_tile(stream, tx, ty); + return kWsShadowOverride; + } + } + *entry = source_tile(stream, tx, ty); + return kWsShadowExact; } void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { @@ -167,6 +214,7 @@ void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) (ppu->bgmode & 0x37)==1 && ppu->bgXsc[0]==0x51 && ppu->bgXsc[1]==0x59; bool any=false; memset(s_tile_keys,0,sizeof(s_tile_keys)); + memset(s_door_keys,0,sizeof(s_door_keys)); if (scene) for (unsigned layer=0; layer<2; ++layer) { const MmxBgStream *stream=&kBgStreams[layer]; int32_t wx=wram16(stream->worldXAddr), wy=wram16(stream->worldYAddr); @@ -176,7 +224,7 @@ void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) wx+=dx; wy+=dy; if (!stream_valid(stream,wx,wy)) continue; WsShadowSetWorld(layer,(uint32_t)wx,(uint32_t)wy); - WsShadowSetWorldProvider(layer,world_provider,(void *)stream); + WsShadowSetWorldProvider(layer,world_provider,(void *)stream,layer==0); WsShadowSetBlankTile(layer,-1); WsShadowSetRespectGameWrites(layer,60); any=true; diff --git a/runner/src/snes/mmx_door.h b/runner/src/snes/mmx_door.h new file mode 100644 index 00000000..50df0600 --- /dev/null +++ b/runner/src/snes/mmx_door.h @@ -0,0 +1,25 @@ +#ifndef SNESRECOMP_MMX_DOOR_H +#define SNESRECOMP_MMX_DOOR_H +#include +#include + +/* X1's structural door test, also used by X2/X3. Require the full mirrored + * three-metatile body and distinct cap/middle tile IDs. Symmetric unflipped + * walls can have similar caps and must not be mistaken for door faces. */ +static inline bool MmxDoorBody(const uint16_t words[3][4]) { + const uint16_t *t = words[0], *m = words[1], *b = words[2]; + const uint16_t h = 0x4000, v = 0x8000; + return t[1] == (uint16_t)(t[0] ^ h) && + t[3] == (uint16_t)(t[2] ^ h) && + m[1] == (uint16_t)(m[0] ^ h) && + m[2] == (uint16_t)(m[0] ^ v) && + m[3] == (uint16_t)(m[0] ^ h ^ v) && + b[0] == (uint16_t)(t[2] ^ 0x8000) && + b[1] == (uint16_t)(t[3] ^ 0x8000) && + b[2] == (uint16_t)(t[0] ^ 0x8000) && + b[3] == (uint16_t)(t[1] ^ 0x8000) && + (t[0] & 1023) != (t[2] & 1023) && + (m[0] & 1023) != (t[0] & 1023) && + (m[0] & 1023) != (t[2] & 1023); +} +#endif diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 7440a48a..1ac39f28 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -30,6 +30,7 @@ typedef struct WsShadowLayer { uint32_t scrollY; WsShadowWorldProvider provider; void *providerContext; + bool providerNativeOverrides; uint16_t mapBaseWord; uint16_t *entries; uint8_t *valid; @@ -181,6 +182,7 @@ void WsShadowReset(void) { layer->dir = 0; layer->provider = NULL; layer->providerContext = NULL; + layer->providerNativeOverrides = false; layer->haveLastOrigin = false; layer->haveRetainMapBase = false; layer->havePrevLive = false; @@ -225,6 +227,7 @@ void WsShadowSetWorld(int layerIndex, uint32_t worldX, uint32_t worldY) { layer->worldSet = true; layer->provider = NULL; layer->providerContext = NULL; + layer->providerNativeOverrides = false; if (layer->haveWorldAnchor && worldX != layer->worldX) layer->dir = ((int32_t)(worldX - layer->worldX) > 0) ? 1 : -1; layer->haveWorldAnchor = true; @@ -235,10 +238,11 @@ void WsShadowSetWorld(int layerIndex, uint32_t worldX, uint32_t worldY) { } void WsShadowSetWorldProvider(int layerIndex, WsShadowWorldProvider provider, - void *context) { + void *context, bool nativeOverrides) { if (layerIndex < 0 || layerIndex >= kLayers) return; s_layers[layerIndex].provider = provider; s_layers[layerIndex].providerContext = context; + s_layers[layerIndex].providerNativeOverrides = provider && nativeOverrides; } void WsShadowSetScroll(int layerIndex, uint32_t scrollX, uint32_t scrollY) { @@ -1302,8 +1306,10 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, * an exact one-tile seam before margin rendering begins. */ const int tile_pixels = 1 << (layer->tileShift ? layer->tileShift : 3); + const bool native = screenX < 256 && screenX + tile_pixels > 0; if (!layer->active || - (screenX >= 0 && screenX + tile_pixels <= 256)) + (screenX >= 0 && screenX + tile_pixels <= 256 && + !layer->providerNativeOverrides)) return realTile; /* Layered margin sources, exact-first: (1) the world-keyed history — @@ -1329,7 +1335,7 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, if (layer->provider && !layer->retainHistory) { /* One tile can straddle the native edge under fine scroll. Its native * pixels remain authoritative even during an in-flight column upload. */ - if (screenX < 256 && screenX + tile_pixels > 0) return realTile; + if (native && !layer->providerNativeOverrides) return realTile; int32_t dx = (hScroll - layer->scrollX) & 0x3ff; int32_t dy = (wrappedY - layer->scrollY) & 0x3ff; if (dx >= 512) dx -= 1024; @@ -1337,12 +1343,16 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, worldX += dx; worldY = (int32_t)layer->worldY + dy; if (worldX >= 0 && worldY >= 0) { - exact = layer->provider(layer->providerContext, + WsShadowSource source = layer->provider(layer->providerContext, (uint32_t)worldX >> shift, (uint32_t)worldY >> shift, &exactEntry); + if (source == kWsShadowOverride) return exactEntry; + if (native) return realTile; + exact = source == kWsShadowExact; if (exact) WsShadowForceTile(layerIndex, (uint32_t)worldX >> shift, (uint32_t)worldY >> shift, exactEntry); } + if (native) return realTile; } if (layer->entries && worldX >= 0 && worldY >= 0) { uint16_t entry; diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index ab6fe99c..057dee4a 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -5,8 +5,8 @@ #include // Presentation-only, world-keyed BG tilemap storage for streaming games. -// Games opt a layer in each frame; the authentic 256-pixel region always -// continues to read real VRAM. Supports 8x8 and 16x16 (BGMODE big-tile) +// Games opt a layer in each frame; the authentic 256-pixel region reads real +// VRAM unless an explicit presentation override applies. Supports 8x8 and 16x16 (BGMODE big-tile) // layers, and both 64-wide and streamed 32-wide tilemaps. enum { kWsShadowXTiles = 4096, @@ -26,11 +26,18 @@ void WsShadowSetWorld(int layer, uint32_t worldX, uint32_t worldY); /* Exact, uncached map source for a streaming title. Register after SetWorld * each frame. Queries use the rendered scanline's scroll phase and signed * 10-bit deltas, and cover world positions beyond the history cache bounds. - * Native pixels retain VRAM; recent explicit game writes keep precedence. */ -typedef bool (*WsShadowWorldProvider)(void *context, uint32_t worldTileX, + * Native pixels retain VRAM; recent explicit game writes keep precedence, + * except for an explicit Override result. nativeOverrides additionally allows + * that result inside the native region; Exact never replaces native VRAM. */ +typedef enum WsShadowSource { + kWsShadowMiss, kWsShadowExact, kWsShadowOverride +} WsShadowSource; +/* Override is a presentation substitution (for example the hidden face of a + * two-sided boss door), which also wins over recent dynamic game uploads. */ +typedef WsShadowSource (*WsShadowWorldProvider)(void *context, uint32_t worldTileX, uint32_t worldTileY, uint16_t *entry); void WsShadowSetWorldProvider(int layer, WsShadowWorldProvider provider, - void *context); + void *context, bool nativeOverrides); /* PPU scroll used to address the streaming VRAM tilemap window and to convert * scanline-wrapped Y back to world Y. Defaults to the SetWorld values. */ diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index 481bc346..f854ed70 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -5,25 +5,40 @@ #include #include "snes/ppu.h" #include "snes/ws_shadow.h" +#include "snes/mmx_door.h" int snes_frame_counter; static Ppu p; static unsigned calls, last_x, last_y; -static bool provider(void *context, uint32_t x, uint32_t y, uint16_t *entry) { +static WsShadowSource result = kWsShadowExact; +static WsShadowSource provider(void *context, uint32_t x, uint32_t y, uint16_t *entry) { assert(context == &p); ++calls; last_x=x; last_y=y; *entry=(uint16_t)(0x2000 | ((x + y * 3) & 1023)); - return true; + return result; } static void setup(unsigned x, unsigned y) { WsShadowReset(); memset(&p,0,sizeof(p)); p.bgXsc[0]=0x51; WsShadowSetWorld(0,x,y); - WsShadowSetWorldProvider(0,provider,&p); + WsShadowSetWorldProvider(0,provider,&p,false); WsShadowSetRespectGameWrites(0,60); WsShadowFrame(&p); calls=0; } int main(void) { + uint16_t x3[3][4]={{0x1813,0x5813,0x1814,0x5814}, + {0x1815,0x5815,0x9815,0xd815}, + {0x9814,0xd814,0x9813,0xd813}}; + uint16_t x2[3][4]={{0x0410,0x4410,0x0420,0x4420}, + {0x0430,0x4430,0x8430,0xc430}, + {0x8420,0xc420,0x8410,0xc410}}; + uint16_t wall[3][4]={{0x0c07,0x0c07,0x0c42,0x0c42}, + {0x0c43,0x0c43,0x0c43,0x0c43}, + {0x8c42,0x8c42,0x8c07,0x8c07}}; + assert(!MmxDoorBody(wall)); + assert(MmxDoorBody(x3) && MmxDoorBody(x2)); + x3[1][0] ^= 1; assert(!MmxDoorBody(x3)); + memset(x2,0,sizeof(x2)); assert(!MmxDoorBody(x2)); setup(0x1c00,0x1300); assert(WsShadowTile(0,100,0x300,0,0x5000,0x1234)==0x1234 && calls==0); assert(WsShadowTile(0,255,0x300,0,0x5000,0x5678)==0x5678 && calls==0); @@ -51,6 +66,17 @@ int main(void) { setup(0,0x200); WsShadowSetWorld(0,0,0x200); WsShadowFrame(&p); calls=0; (void)WsShadowTile(0,256,0x200,0,0x5400,0); assert(calls==0); + /* Presentation substitutions may hide a duplicate door even inside the + * native region or following a fresh game upload; ordinary exact entries + * must still leave native pixels untouched. */ + setup(0,0x200); + WsShadowSetWorldProvider(0,provider,&p,true); + assert(WsShadowTile(0,100,0x200,0,0x5000,0x1234)==0x1234); + result=kWsShadowOverride; + assert(WsShadowTile(0,100,0x200,0,0x5000,0x1234)!=0x1234); + WsShadowOnVramWrite(0x5400,0xabcd); + assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); + result=kWsShadowExact; puts("exact world provider: high-Y, signed raster phase, native pixels, dynamic writes and lifecycle passed"); return 0; } From 28f661e16120a3a24e4b6f119be7c278c1567d5a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 16:10:48 -0700 Subject: [PATCH 04/17] tools: optionally capture raw libretro device snapshots --- tools/snesref/README.md | 1 + tools/snesref/frontend.cpp | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/tools/snesref/README.md b/tools/snesref/README.md index 138344e7..f10019de 100644 --- a/tools/snesref/README.md +++ b/tools/snesref/README.md @@ -131,6 +131,7 @@ Environment variables select non-interactive capture outputs: | `SNESREF_DSPREG_TRACE_FILE=path` | Trace S-DSP registers when supported by a patched core. | | `SNESREF_SCRIPT=path` | Scene-keyed script (grammar below). Exit 0 on `quit`, 3 on an `until` timeout, 6 on a malformed script. Headless runs without `quit`/`SNESREF_FRAMES` exit at end of script. | | `SNESREF_DUMP_DIR=path` | Directory for script `dump` outputs (default: cwd). | +| `SNESREF_DUMP_SERIALIZED=1` | Also write `.core-state.bin` on script dumps when the core supports serialization (up to 16 MiB). This is an opaque, core/version-specific diagnostic snapshot, useful for inspecting device state absent from a stock core's debug exports. | | `SNESREF_SRAM_IN=path` | Load into `RETRO_MEMORY_SAVE_RAM` after `retro_load_game`, before frame 1. | | `SNESREF_LAYERS=hex` | Layer mask, bit0=BG1 bit1=BG2 bit2=BG3 bit3=BG4 bit4=OBJ (default `1F`). Maps to snes9x `snes9x_layer_1..5` (5 = sprites). | | `SNESREF_NO_COLORMATH=1` | `snes9x_gfx_transp=disabled`. | diff --git a/tools/snesref/frontend.cpp b/tools/snesref/frontend.cpp index 94b7b4e0..77e27b7a 100644 --- a/tools/snesref/frontend.cpp +++ b/tools/snesref/frontend.cpp @@ -876,6 +876,16 @@ static void do_dump(const char* tag) { dump_memory(tag, "wram.bin", RETRO_MEMORY_SYSTEM_RAM, &wram_n); dump_memory(tag, "vram.bin", RETRO_MEMORY_VIDEO_RAM, &vram_n); dump_memory(tag, "sram.bin", RETRO_MEMORY_SAVE_RAM, &sram_n); + /* Optional core snapshot provides raw device state when a stock libretro + * core has no snesref_dbg_* exports (notably OAM/CGRAM investigations). */ + if (getenv("SNESREF_DUMP_SERIALIZED")) { + size_t n = p_retro_serialize_size(); + if (n && n <= (16u << 20)) { + std::vector state(n); + if (p_retro_serialize(state.data(), n)) + write_file(tag, "core-state.bin", state.data(), n); + } + } bool have_dbg = p_dbg_cgram && p_dbg_oam && p_dbg_regs_json; if (have_dbg) { From 0f68883a9b93f63b3f3d1bf0dac82f7a3c3b3d4c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 20:01:18 -0700 Subject: [PATCH 05/17] Fix MMX23 blank sky and mode2 widescreen map continuation --- runner/src/snes/mmx23_background.c | 34 +++++++- runner/src/snes/ppu.c | 36 ++++++++- runner/src/snes/ws_shadow.c | 8 +- tests/ppu/mmx23_stream_test.c | 86 +++++++++++++++++++++ tests/ppu/ppu_composition_regression_test.c | 50 ++++++++++-- tests/ppu/test_world_provider.py | 8 +- tests/ppu/ws_world_provider_test.c | 2 + 7 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 tests/ppu/mmx23_stream_test.c diff --git a/runner/src/snes/mmx23_background.c b/runner/src/snes/mmx23_background.c index 8a74a098..8c35b2cb 100644 --- a/runner/src/snes/mmx23_background.c +++ b/runner/src/snes/mmx23_background.c @@ -90,6 +90,12 @@ static bool stream_valid(const MmxBgStream *s, int32_t wx, int32_t wy) { * flicker the margins off for a frame. Two or more = not our scene. */ if (miss > 1) return false; + /* BG1 is the terrain stream, including deliberately empty sky during the + * rocket ascent. Rejecting its uniform native window exposes old circular + * VRAM columns in the gutters. Only BG2 is repurposed as a moving object + * layer and needs the diversity safeguard below. */ + if (s == &kBgStreams[0]) + return true; /* Diversity: a near-uniform native sample proves nothing — some scenes * repurpose BG2 as a mostly-transparent OBJECT layer (X-Hunter tower * lifts live in the BG2 map and move by scroll). There the provider's @@ -163,6 +169,15 @@ static bool door_body(int x, int y) { unsigned cache = (key * 2654435761u) >> 20; if (s_door_keys[cache] == key + 1) return s_door_values[cache]; bool found = false; + /* Every row of the strict door body has horizontally mirrored halves. + * Reject ordinary terrain/empty sky before walking all three possible + * cap positions. This matters when the valid BG1 stream is mostly sky. */ + uint16_t left_half=source_tile(&kBgStreams[0],tx*2,ty*2); + uint16_t right_half=source_tile(&kBgStreams[0],tx*2+1,ty*2); + if (right_half != (uint16_t)(left_half ^ 0x4000)) { + s_door_keys[cache]=key+1; s_door_values[cache]=false; + return false; + } for (int row = 0; row < 3 && !found; ++row) { int top = (int)ty - row; if (top < 0 || top + 2 >= 512) continue; @@ -210,20 +225,31 @@ void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) e=getenv("SNESRECOMP_WS_BG_MARGINS_DEBUG"); debug=e && e[0]!='0'; } s_snes=snes; s_ppu=ppu; s_ram=ram; + unsigned mode=ppu->bgmode & 0x37; + /* Ostrich's heat shimmer switches to mode2 offset-per-tile while keeping + * the same two 4bpp streams. The renderer supplies the effective raster/ + * tile scroll to WsShadowTile; source validation still applies. */ bool scene = on && enabled && snes && ppu && ram && - (ppu->bgmode & 0x37)==1 && ppu->bgXsc[0]==0x51 && ppu->bgXsc[1]==0x59; + (mode==1 || mode==2) && ppu->bgXsc[0]==0x51 && ppu->bgXsc[1]==0x59; bool any=false; memset(s_tile_keys,0,sizeof(s_tile_keys)); memset(s_door_keys,0,sizeof(s_door_keys)); if (scene) for (unsigned layer=0; layer<2; ++layer) { const MmxBgStream *stream=&kBgStreams[layer]; int32_t wx=wram16(stream->worldXAddr), wy=wram16(stream->worldYAddr); - int32_t dx=(ppu->hScroll[layer]-wx)&1023, dy=(ppu->vScroll[layer]-wy)&1023; - if (dx>=512) dx-=1024; - if (dy>=512) dy-=1024; + /* These streams use 64x32 maps: hardware scrolls alias every512/256 + * pixels, not every1024. Signed parallax in the Ostrich arena uses the + * equivalent scroll near1024; lifting it in a1024-wide domain selected + * a negative source and exposed the previous room's VRAM columns. */ + int32_t dx=(ppu->hScroll[layer]-wx)&511, dy=(ppu->vScroll[layer]-wy)&255; + if (dx>=256) dx-=512; + if (dy>=128) dy-=256; wx+=dx; wy+=dy; if (!stream_valid(stream,wx,wy)) continue; WsShadowSetWorld(layer,(uint32_t)wx,(uint32_t)wy); + /* World and hardware origins can differ by a whole map wrap. Subsequent + * raster deltas must be measured from the actual hardware scroll. */ + WsShadowSetScroll(layer,ppu->hScroll[layer],ppu->vScroll[layer]); WsShadowSetWorldProvider(layer,world_provider,(void *)stream,layer==0); WsShadowSetBlankTile(layer,-1); WsShadowSetRespectGameWrites(layer,60); diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 850a7b02..8a23feae 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -1390,13 +1390,17 @@ static void PpuDrawBackgroundBig(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, } } -static uint16 PpuReadTilemapEntry(Ppu *ppu, uint layer, int tx, int ty) { +static uint16 PpuTilemapWordAddress(Ppu *ppu, uint layer, int tx, int ty) { int addr = PPU_bgTilemapAdr(ppu, layer) + ((ty & 31) << 5) + (tx & 31); if (((ty >> 5) & 1) && PPU_bgTilemapHigher(ppu, layer)) addr += PPU_bgTilemapWider(ppu, layer) ? 0x800 : 0x400; if (((tx >> 5) & 1) && PPU_bgTilemapWider(ppu, layer)) addr += 0x400; - return PpuRenderVram(ppu)[addr & 0x7fff]; + return (uint16)(addr & 0x7fff); +} + +static uint16 PpuReadTilemapEntry(Ppu *ppu, uint layer, int tx, int ty) { + return PpuRenderVram(ppu)[PpuTilemapWordAddress(ppu, layer, tx, ty)]; } /* Capture a native-width Mode 1 4bpp layer independently of the compositor. @@ -1508,6 +1512,7 @@ static void PpuDrawBackground_4bpp_opt(Ppu *ppu, uint y, bool sub, uint layer, const int opt_hrow = (opt_vscroll & opt_coord_mask) >> opt_shift; const int opt_vrow = ((opt_vscroll + 8) & opt_coord_mask) >> opt_shift; const uint16 enable = (uint16)(0x2000u << layer); + const bool ws_shadow = WsShadowLayerActive((int)layer); for (size_t windex = 0; windex < win.nr; windex++) { if (win.bits & (1 << windex)) @@ -1546,10 +1551,33 @@ static void PpuDrawBackground_4bpp_opt(Ppu *ppu, uint y, bool sub, uint layer, if (width > (unsigned)(right - screen_x)) width = right - screen_x; + const uint16 map_word = PpuTilemapWordAddress( + ppu, layer, sx >> tile_shift, sy >> tile_shift); + const uint16 real_tile = PpuRenderVram(ppu)[map_word]; + uint16 shadow_tile = real_tile; + if (ws_shadow) { + /* Preserve the native segment. Resolve its continuation with the + * effective OPT offsets, not the unmodified global scroll. The + * segment ends at the next source-tile boundary, so one lookup also + * serves a straddling native/margin segment without changing native + * pixels or doing a world lookup for every output pixel. */ + const int margin_x = screen_x < 0 ? screen_x : + screen_x >= 256 ? screen_x : 256; + if (screen_x < 0 || screen_x + (int)width > 256) { + const unsigned map_height = + (PPU_bgTilemapHigher(ppu, layer) ? 64u : 32u) << tile_shift; + int vdelta = (voffset - vscroll) & (map_height - 1); + if (vdelta >= (int)map_height / 2) vdelta -= map_height; + shadow_tile = WsShadowTile( + (int)layer, margin_x + sample_bias, vscroll + vdelta + y, + (uint16_t)hoffset, map_word, real_tile); + } + } + for (unsigned i = 0; i < width; i++, dstz++) { unsigned source_x = (sx + i) & coord_mask; - uint16 tile = PpuReadTilemapEntry( - ppu, layer, source_x >> tile_shift, sy >> tile_shift); + const int output_x = screen_x + (int)i; + uint16 tile = output_x < 0 || output_x >= 256 ? shadow_tile : real_tile; unsigned px = source_x & tile_mask; unsigned py = sy & tile_mask; if (tile & 0x4000) diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 1ac39f28..70b16801 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -1336,9 +1336,13 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, /* One tile can straddle the native edge under fine scroll. Its native * pixels remain authoritative even during an in-flight column upload. */ if (native && !layer->providerNativeOverrides) return realTile; - int32_t dx = (hScroll - layer->scrollX) & 0x3ff; + const int32_t map_width = (layer->wide ? 64 : 32) << shift; + int32_t dx = (hScroll - layer->scrollX) & (map_width - 1); + /* wrappedY includes the output scanline (up to223), so folding this + * delta to half a256px map would wrap the bottom half into the prior + * world screen. OPT scroll aliases are normalized before adding y. */ int32_t dy = (wrappedY - layer->scrollY) & 0x3ff; - if (dx >= 512) dx -= 1024; + if (dx >= map_width / 2) dx -= map_width; if (dy >= 512) dy -= 1024; worldX += dx; worldY = (int32_t)layer->worldY + dy; diff --git a/tests/ppu/mmx23_stream_test.c b/tests/ppu/mmx23_stream_test.c new file mode 100644 index 00000000..d777e40f --- /dev/null +++ b/tests/ppu/mmx23_stream_test.c @@ -0,0 +1,86 @@ +/* Exercise the production source selector against deliberately stale VRAM. + * No game ROM is needed: the fixture supplies a small metatile dictionary. */ +#include +#include +#include +#include "snes/cart.h" +#include "snes/ppu.h" +#include "snes/ws_shadow.h" + +int snes_frame_counter; +static uint8_t rom[65536], ram[0x20000]; +static Ppu p; +static Snes snes; +static Cart cart; +uint8_t cart_read(Cart *unused, uint8_t bank, uint16_t address) { + (void)unused; assert(bank == 0x0c); return rom[address]; +} +#include "../../runner/src/snes/mmx23_background.c" + +static void put(uint8_t *data, unsigned address, unsigned value) { + data[address] = (uint8_t)value; data[address+1] = (uint8_t)(value>>8); +} +static void init(void) { + WsShadowReset(); memset(&p,0,sizeof(p)); memset(ram,0,sizeof(ram)); + memset(rom,0,sizeof(rom)); snes.cart=&cart; + p.bgmode=1; p.bgXsc[0]=0x51; p.bgXsc[1]=0x59; + for (int i=0;i<2;i++) { + put(ram,0x9c5+i*3,0x8000); ram[0x9c7+i*3]=0x0c; + } + for (unsigned i=0;i<4;i++) put(rom,0x8000+i*2,0x0c00); + for (unsigned i=0;i<0x800;i++) p.vram[0x5000+i]=p.vram[0x5800+i]=0x0c00; +} +int main(void) { + init(); put(ram,0x1e5d,7680); put(ram,0x1e60,4106); + p.hScroll[0]=512; p.vScroll[0]=10; + /* Blank sky is valid terrain. A stale outside column must not disable the + * provider, while the repurposed-object safeguard remains on blank BG2. */ + p.vram[0x5000+27]=0xdead; + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(WsShadowLayerActive(0)); assert(!WsShadowLayerActive(1)); + assert(WsShadowTile(0,-40,10,512,0x501b,0xdead)==0x0c00); + assert(WsShadowTile(0,100,10,512,0x5000,0x1234)==0x1234); + /* A genuinely different foreground still fails native self-validation. */ + p.vram[vram_word(&kBgStreams[0],7690,4118)]=0x1234; + p.vram[vram_word(&kBgStreams[0],7710,4154)]=0x1234; + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(!WsShadowLayerActive(0)); + + init(); + for (unsigned block=0;block<256;block++) { + put(ram,0xa600+block*2,block); + for (unsigned q=0;q<4;q++) put(rom,0x8000+block*8+q*2,0x1000+block*4+q); + } + put(ram,0x1e9d,256); put(ram,0x1ea0,256); + p.hScroll[1]=968; p.vScroll[1]=256; + s_ram=ram; s_snes=&snes; + for (unsigned y=0;y<256;y+=8) for (unsigned x=0;x<512;x+=8) + p.vram[vram_word(&kBgStreams[1],456+x,256+y)]=world_tile(&kBgStreams[1],456+x,256+y); + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(WsShadowLayerActive(1)); + assert(WsShadowWorldX(1)==456 && WsShadowWorldY(1)==256); + assert(WsShadowScrollX(1)==968 && WsShadowScrollY(1)==256); + assert(WsShadowTile(1,-40,256,968,0x5800,0xdead)==world_tile(&kBgStreams[1],416,256)); + assert(WsShadowTile(1,256,264,968,0x5800,0xdead)==world_tile(&kBgStreams[1],712,264)); + p.bgmode=2; /* Same source maps during the arena's offset-per-tile shimmer. */ + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(WsShadowLayerActive(1)); + assert(WsShadowTile(1,-40,256,968,0x5800,0xdead)==world_tile(&kBgStreams[1],416,256)); + p.bgmode=3; + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(!WsShadowLayerActive(1)); + init(); s_ram=ram; s_snes=&snes; + const uint16_t doors[2][3][4]={ + {{0x0410,0x4410,0x0420,0x4420},{0x0430,0x4430,0x8430,0xc430},{0x8420,0xc420,0x8410,0xc410}}, + {{0x1813,0x5813,0x1814,0x5814},{0x1815,0x5815,0x9815,0xd815},{0x9814,0xd814,0x9813,0xd813}}}; + for (unsigned game=0;game<2;game++) { + memset(s_tile_keys,0,sizeof(s_tile_keys)); memset(s_door_keys,0,sizeof(s_door_keys)); + for (unsigned row=0;row<3;row++) { + put(ram,0x2800+row*32,row); + for (unsigned q=0;q<4;q++) put(rom,0x8000+row*8+q*2,doors[game][row][q]); + } + assert(door_body(0,0) && door_body(0,16) && door_body(0,32)); + } + puts("X2/X3 source selection: blank terrain, object safeguard and 512px parallax alias passed"); + return 0; +} diff --git a/tests/ppu/ppu_composition_regression_test.c b/tests/ppu/ppu_composition_regression_test.c index 08710268..00ada887 100644 --- a/tests/ppu/ppu_composition_regression_test.c +++ b/tests/ppu/ppu_composition_regression_test.c @@ -13,21 +13,24 @@ Snes *g_snes; int snes_frame_counter; unsigned char g_snesrecomp_last_hdmaen; +static bool test_shadow_active; +static unsigned test_shadow_calls, test_shadow_hscroll, test_shadow_y; uint16_t WsShadowTile(int layer, int screen_x, uint32_t wrapped_y, uint16_t h_scroll, uint16_t map_word_adr, uint16_t real_tile) { (void)layer; - (void)screen_x; - (void)wrapped_y; - (void)h_scroll; (void)map_word_adr; + if (test_shadow_active && (screen_x < 0 || screen_x >= 256)) { + ++test_shadow_calls; test_shadow_hscroll=h_scroll; test_shadow_y=wrapped_y; + return 2; + } return real_tile; } bool WsShadowLayerActive(int layer) { (void)layer; - return false; + return test_shadow_active; } uint32_t WsShadowWorldX(int layer) { @@ -66,6 +69,39 @@ void WsShadowOnVramWrite(uint16_t word_adr, uint16_t value) { #endif #include PPU_COMPOSITION_PPU_INCLUDE +static int test_mode2_world_provider(Ppu *ppu) { + ppu_reset(ppu); memset(ppu->vram,0,sizeof(ppu->vram)); + ppu->bgmode=2; ppu->screenEnabled[0]=1; + ppu->bgXsc[0]=0x04; ppu->bgXsc[2]=0x0c; + ppu->hScroll[0]=3; ppu->vScroll[0]=5; ppu->vScroll[2]=1; + for (unsigned i=0;i<0x400;i++) ppu->vram[0x400+i]=1; + for (unsigned i=0;i<32;i++) { + ppu->vram[0xc00+i]=0x2020; ppu->vram[0xc20+i]=0x2027; + } + for (unsigned i=0;i<8;i++) { + ppu->vram[16+i]=0x00ff; /* native tile: palette pixel1 */ + ppu->vram[32+i]=0xff00; /* supplied tile: palette pixel2 */ + } + PpuSetExtraSpaceCentered(ppu,24); + ppu->extraLeftCur=ppu->extraRightCur=24; + memset(&ppu->bgBuffers[0],0,sizeof(ppu->bgBuffers[0])); + test_shadow_calls=0; test_shadow_active=true; + PpuDrawBackground_4bpp_opt(ppu,1,false,0,0xc000,0x8000); + test_shadow_active=false; + if (!test_shadow_calls || test_shadow_hscroll!=0x2023 || test_shadow_y!=0x29 || + ppu->bgBuffers[0].data[kPpuExtraLeftRight+100]!=0x8001 || + ppu->bgBuffers[0].data[kPpuExtraLeftRight-10]!=0x8002 || + ppu->bgBuffers[0].data[kPpuExtraLeftRight+260]!=0x8002) { + fprintf(stderr,"Mode2 world provider: calls=%u h=%x y=%x pixels=%x/%x/%x\n", + test_shadow_calls,test_shadow_hscroll,test_shadow_y, + ppu->bgBuffers[0].data[kPpuExtraLeftRight+100], + ppu->bgBuffers[0].data[kPpuExtraLeftRight-10], + ppu->bgBuffers[0].data[kPpuExtraLeftRight+260]); + return 1; + } + return 0; +} + static uint32_t rng_next(uint32_t *state) { uint32_t x = *state; x ^= x << 13; @@ -187,6 +223,7 @@ static uint64_t render_case_digest(Ppu *ppu, int case_id) { default: break; } + if (case_id >= 8) ppu->bgmode=2; ppu_runLine(ppu, 0); for (int line = 1; line <= 64; line++) @@ -202,7 +239,7 @@ static uint64_t render_case_digest(Ppu *ppu, int case_id) { static int print_render_digest(Ppu *ppu) { uint64_t h = 1469598103934665603ull; - for (int i = 0; i < 8; i++) { + for (int i = 0; i < 16; i++) { uint64_t case_hash = render_case_digest(ppu, i); h = hash_bytes(h, &case_hash, sizeof(case_hash)); } @@ -214,7 +251,7 @@ static int run_render_bench(Ppu *ppu, int loops) { volatile uint64_t sink = 0; clock_t start = clock(); for (int iter = 0; iter < loops; iter++) { - for (int case_id = 0; case_id < 8; case_id++) { + for (int case_id = 0; case_id < 16; case_id++) { uint64_t case_hash = render_case_digest(ppu, case_id); sink = (sink * 1099511628211ull) ^ case_hash ^ (uint64_t)iter; } @@ -245,6 +282,7 @@ int main(int argc, char **argv) { return failures ? 1 : 0; } failures += test_color_window_edges_strict(ppu); + failures += test_mode2_world_provider(ppu); if (failures == 0) failures += print_render_digest(ppu); ppu_free(ppu); diff --git a/tests/ppu/test_world_provider.py b/tests/ppu/test_world_provider.py index a4d8d6e2..1f854636 100644 --- a/tests/ppu/test_world_provider.py +++ b/tests/ppu/test_world_provider.py @@ -8,7 +8,8 @@ ROOT = Path(__file__).resolve().parents[2] -def test_world_provider(): +@pytest.mark.parametrize('source', ['ws_world_provider_test.c', 'mmx23_stream_test.c']) +def test_world_provider(source): cc = os.environ.get('CC') or shutil.which('cc') or shutil.which('gcc') if not cc and os.name == 'nt': mingw = Path('C:/msys64/mingw64/bin/gcc.exe') @@ -19,10 +20,11 @@ def test_world_provider(): with tempfile.TemporaryDirectory(prefix='ws-provider-') as tmp: exe = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test') subprocess.run([cc, '-std=c11', '-O2', '-Irunner/src', - 'tests/ppu/ws_world_provider_test.c', + 'tests/ppu/'+source, 'runner/src/snes/ws_shadow.c', '-o', str(exe)], cwd=ROOT, check=True) subprocess.run([str(exe)], check=True) if __name__ == '__main__': - test_world_provider() + for source in ['ws_world_provider_test.c', 'mmx23_stream_test.c']: + test_world_provider(source) diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index f854ed70..e3b50381 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -50,6 +50,8 @@ int main(void) { assert(last_x==(0x1c00-40+128)/8 && last_y==(0x1300-24)/8); (void)WsShadowTile(0,256,0x308,896,0x5000,0xdead); assert(last_x==(0x1d00-128)/8); + (void)WsShadowTile(0,256,0x3c8,0,0x5400,0xdead); + assert(last_y==(0x1300+200)/8); /* output y is not a signed map-scroll delta */ setup(0,0x200); snes_frame_counter=10; WsShadowOnVramWrite(0x5400,0xabcd); assert(WsShadowTile(0,256,0x200,0,0x5400,0)==0xabcd); From a77c1f729b1a874e49d9de95f0713340cf2d1dbd Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 20:52:58 -0700 Subject: [PATCH 06/17] Extend authored room backdrops only into the right gutter --- .../mmx2-rocket-serges-margins-2026-10-26.md | 45 +++++++++++++++++++ runner/src/snes/mmx23_background.c | 17 ++++++- runner/src/snes/mmx23_background.h | 5 +++ runner/src/snes/ws_shadow.c | 3 +- runner/src/snes/ws_shadow.h | 9 ++-- tests/ppu/mmx23_stream_test.c | 23 ++++++++++ tests/ppu/ws_world_provider_test.c | 4 ++ 7 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 docs/validation/mmx2-rocket-serges-margins-2026-10-26.md diff --git a/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md b/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md new file mode 100644 index 00000000..a787e6a8 --- /dev/null +++ b/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md @@ -0,0 +1,45 @@ +# X2 rocket and Serges margin follow-up + +The rocket ride exposed uniform terrain being rejected, physical scroll aliases +being interpreted as 1024px maps, and mode2 offset-per-tile drawing bypassing the +world source. The resolver now accepts self-validated uniform BG1 terrain while +retaining the moving-object BG2 safeguard, uses the actual 512x256 map aliases, +and receives effective raster/OPT offsets from the mode2 drawing path. X2 also +uses the existing OAM-only HUD anchor so BG3 scenery is no longer displaced. + +Serges's authored room ends at world X0B80. Continue only its BG1 backdrop beyond +that boundary while the original camera is locked and the machine is resident. +The title supplies this room selection; the shared provider offers an optional +right boundary and a margin-only override. Native pixels always retain VRAM, +and the real left-side scenery/door remains visible at wider aspects. BG2 and +OAM, including the moving machine, pilot and platforms, are not repeated. + +Validation uses private muted profiles and verified unmodified USA ROMs: + +- The actual rocket control/camera ascent and original arena handoff complete + over 780 frames at hybrid342 and interpreter684. No audited result flags are + written. Same-width before/after samples agree in full WRAM and VRAM. +- All 24 cropped native-view samples agree outside the relocated HUD. One + cross-width arena sample has a differing scratch byte at WRAM10; full + cross-width RAM identity is not claimed. +- Serges entrance replays at hybrid342, hybrid684 and interpreter684 alter only + the right gutter. All native/left pixels, full WRAM and VRAM agree with the + prior build in 21 samples per configuration. Current-build save/load agrees + in all RAM and pixels at342 and684. +- Production C tests cover aliasing, uniform terrain, dynamic object rejection, + margin-only precedence over uploads, preservation of native pixels and the + left source, and clearing the optional boundary for ordinary rooms. Actual + mode2 composition and inactive-provider rendering checks also pass. +- Accepted X3 factory and Byte replays retain all sampled pixels and WRAM + through the rocket update. The optional boundary defaults to zero for X3. +- Short serial software/debug samples showed about235..256fps versus + baseline236..249fps. These establish headroom, not release frame-time parity. + +The original-ROM arena reference is valid. Unsuccessful original rocket and +Serges preparation attempts are excluded. The reported incomplete Serges +entrance artwork remains a failed owner observation pending a focused replay; +actor/OAM presence alone does not close it. Owner visual acceptance of the +rocket margins and background extension is also pending. Byte and doors are +already accepted and require no repeat owner handoff. + +Evidence: `G:/Temp/Codex/mmx23-trigger-audit-20261026/burndown`. diff --git a/runner/src/snes/mmx23_background.c b/runner/src/snes/mmx23_background.c index 8c35b2cb..84e5608a 100644 --- a/runner/src/snes/mmx23_background.c +++ b/runner/src/snes/mmx23_background.c @@ -13,6 +13,7 @@ static Snes *s_snes; static Ppu *s_ppu; static uint8_t *s_ram; +static uint16_t s_bg1_repeat_right; /* A frame-local memo avoids decoding the same metatile on all eight raster * lines. Its size is independent of world height; collisions re-resolve. */ static uint32_t s_tile_keys[4096]; @@ -197,6 +198,13 @@ static WsShadowSource world_provider(void *context, uint32_t tx, uint32_t ty, uint16_t *entry) { const MmxBgStream *stream = context; if (tx >= 1024 || ty >= 1024) return kWsShadowMiss; + if (stream == &kBgStreams[0] && s_bg1_repeat_right && + tx >= (uint32_t)s_bg1_repeat_right / 8) { + uint32_t boundary = (uint32_t)s_bg1_repeat_right / 8; + uint32_t sample = boundary - 32 + ((tx - boundary) & 31); + *entry = source_tile(stream, sample, ty); + return kWsShadowMarginOverride; + } int x = (int)tx * 8, y = (int)ty * 8; if (stream == &kBgStreams[0] && door_body(x, y)) { bool left = door_body(x - 16, y), right = door_body(x + 16, y); @@ -217,7 +225,8 @@ static WsShadowSource world_provider(void *context, uint32_t tx, uint32_t ty, return kWsShadowExact; } -void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { +void Mmx23ConfigureWsBgMarginsForRoom(Snes *snes, Ppu *ppu, uint8_t *ram, + bool enabled, uint16_t bg1RepeatRight) { if (!ppu) return; static int on = -1, debug = -1; if (on < 0) { @@ -225,6 +234,8 @@ void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) e=getenv("SNESRECOMP_WS_BG_MARGINS_DEBUG"); debug=e && e[0]!='0'; } s_snes=snes; s_ppu=ppu; s_ram=ram; + s_bg1_repeat_right = bg1RepeatRight >= 256 && !(bg1RepeatRight & 7) + ? bg1RepeatRight : 0; unsigned mode=ppu->bgmode & 0x37; /* Ostrich's heat shimmer switches to mode2 offset-per-tile while keeping * the same two 4bpp streams. The renderer supplies the effective raster/ @@ -265,3 +276,7 @@ void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) scene,WsShadowLayerActive(0),WsShadowLayerActive(1)); } } + +void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { + Mmx23ConfigureWsBgMarginsForRoom(snes,ppu,ram,enabled,0); +} diff --git a/runner/src/snes/mmx23_background.h b/runner/src/snes/mmx23_background.h index 0eb33757..d238b118 100644 --- a/runner/src/snes/mmx23_background.h +++ b/runner/src/snes/mmx23_background.h @@ -6,4 +6,9 @@ struct Snes; struct Ppu; void Mmx23ConfigureWsBgMargins(struct Snes *snes, struct Ppu *ppu, uint8_t *ram, bool enabled); +/* Optional authored right boundary: repeat the preceding 256px of BG1 into + * the right gutter only. Zero disables this room-specific presentation. */ +void Mmx23ConfigureWsBgMarginsForRoom(struct Snes *snes, struct Ppu *ppu, + uint8_t *ram, bool enabled, + uint16_t bg1RepeatRight); #endif diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 70b16801..e3f0729f 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -1350,7 +1350,8 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, WsShadowSource source = layer->provider(layer->providerContext, (uint32_t)worldX >> shift, (uint32_t)worldY >> shift, &exactEntry); - if (source == kWsShadowOverride) return exactEntry; + if (source == kWsShadowOverride || + (source == kWsShadowMarginOverride && !native)) return exactEntry; if (native) return realTile; exact = source == kWsShadowExact; if (exact) WsShadowForceTile(layerIndex, (uint32_t)worldX >> shift, diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index 057dee4a..2dbdfe50 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -24,13 +24,14 @@ void WsShadowReset(void); void WsShadowSetWorld(int layer, uint32_t worldX, uint32_t worldY); /* Exact, uncached map source for a streaming title. Register after SetWorld - * each frame. Queries use the rendered scanline's scroll phase and signed - * 10-bit deltas, and cover world positions beyond the history cache bounds. + * each frame. Queries use the rendered scanline's scroll phase and map-width + * horizontal aliases, and cover positions beyond the history cache bounds. * Native pixels retain VRAM; recent explicit game writes keep precedence, * except for an explicit Override result. nativeOverrides additionally allows - * that result inside the native region; Exact never replaces native VRAM. */ + * that result inside the native region. MarginOverride wins over uploads only + * outside the native region; Exact never replaces native VRAM. */ typedef enum WsShadowSource { - kWsShadowMiss, kWsShadowExact, kWsShadowOverride + kWsShadowMiss, kWsShadowExact, kWsShadowOverride, kWsShadowMarginOverride } WsShadowSource; /* Override is a presentation substitution (for example the hidden face of a * two-sided boss door), which also wins over recent dynamic game uploads. */ diff --git a/tests/ppu/mmx23_stream_test.c b/tests/ppu/mmx23_stream_test.c index d777e40f..535fc115 100644 --- a/tests/ppu/mmx23_stream_test.c +++ b/tests/ppu/mmx23_stream_test.c @@ -81,6 +81,29 @@ int main(void) { } assert(door_body(0,0) && door_body(0,16) && door_body(0,32)); } + init(); s_ram=ram; s_snes=&snes; + for (unsigned block=1;block<256;block++) + for (unsigned q=0;q<4;q++) put(rom,0x8000+block*8+q*2,0x1000+block*4+q); + ram[0xe800+32+10]=1; ram[0xe800+32+11]=2; + for (unsigned row=0;row<16;row++) for (unsigned col=0;col<16;col++) { + unsigned block=1+((row*16+col)%255); + put(ram,0x2800+512+row*32+col*2,block); + put(ram,0x2800+1024+row*32+col*2,col<8?block:0); + } + put(ram,0x1e5d,0xa80); put(ram,0x1e60,0x100); + p.hScroll[0]=0x280; p.vScroll[0]=0x100; + for (unsigned y=0;y<224;y+=8) for (unsigned x=0;x<256;x+=8) + p.vram[vram_word(&kBgStreams[0],0xa80+x,0x100+y)]=world_tile(&kBgStreams[0],0xa80+x,0x100+y); + Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0xb80); + assert(WsShadowLayerActive(0)); + assert(world_tile(&kBgStreams[0],0xb80,0x100)==0x0c00); + snes_frame_counter=100; + WsShadowOnVramWrite(vram_word(&kBgStreams[0],0xb80,0x100),0xdead); + assert(WsShadowTile(0,256,0x100,0x280,0x5000,0xdead)==world_tile(&kBgStreams[0],0xa80,0x100)); + assert(WsShadowTile(0,100,0x100,0x280,0x5000,0x1234)==0x1234); + assert(WsShadowTile(0,-40,0x100,0x280,0x5000,0xdead)==world_tile(&kBgStreams[0],0xa58,0x100)); + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(!s_bg1_repeat_right); /* ordinary room/load clears the extension */ puts("X2/X3 source selection: blank terrain, object safeguard and 512px parallax alias passed"); return 0; } diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index e3b50381..972340a6 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -78,6 +78,10 @@ int main(void) { assert(WsShadowTile(0,100,0x200,0,0x5000,0x1234)!=0x1234); WsShadowOnVramWrite(0x5400,0xabcd); assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); + result=kWsShadowMarginOverride; + assert(WsShadowTile(0,100,0x200,0,0x5000,0x1234)==0x1234); + WsShadowOnVramWrite(0x5400,0xabcd); + assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); result=kWsShadowExact; puts("exact world provider: high-Y, signed raster phase, native pixels, dynamic writes and lifecycle passed"); return 0; From d980d85273df76f48c216aeb82cdbcb2ca720b7c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 22:10:32 -0700 Subject: [PATCH 07/17] Fix X2 sparse boss canvas and parallax margin selection --- .../mmx2-rocket-serges-margins-2026-10-26.md | 39 ++++++++++++++++--- runner/src/snes/mmx23_background.c | 21 +++++++--- runner/src/snes/mmx23_background.h | 7 +++- runner/src/snes/ws_shadow.c | 9 +++++ runner/src/snes/ws_shadow.h | 4 ++ tests/ppu/mmx23_stream_test.c | 25 +++++++++++- tests/ppu/ws_world_provider_test.c | 11 ++++++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md b/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md index a787e6a8..28a8369c 100644 --- a/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md +++ b/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md @@ -14,6 +14,21 @@ right boundary and a margin-only override. Native pixels always retain VRAM, and the real left-side scenery/door remains visible at wider aspects. BG2 and OAM, including the moving machine, pilot and platforms, are not repeated. +Two follow-ups address the remaining owner observations. During Ostrich's +entrance, BG2 is a complete circular parallax canvas. Raster scrolling selects +world X768 where the finite layout contains black padding, although VRAM +already holds the correct scenery. The title selects actual VRAM for this +layer in the destination arena; the rocket ride retains its streaming source. + +Serges's complete front is already in the live BG2 layout at local Y1024. +Before it enters the native view, the almost-empty native samples reject that +source under the general moving-object safeguard. The original streamer has +not yet uploaded the lower cannons into the widened edge, so VRAM fallback +shows an incomplete front. Select this known loaded source canvas while the +original background actor and camera lock are active. Native validation still +applies; only gutters use the live map, including destruction edits. Parent +position, entrance duration, collision and result flags are untouched. + Validation uses private muted profiles and verified unmodified USA ROMs: - The actual rocket control/camera ascent and original arena handoff complete @@ -34,12 +49,26 @@ Validation uses private muted profiles and verified unmodified USA ROMs: through the rocket update. The optional boundary defaults to zero for X3. - Short serial software/debug samples showed about235..256fps versus baseline236..249fps. These establish headroom, not release frame-time parity. +- Ostrich entrance:20 samples over300 frames per width at342 and interpreter684 + remove the rectangles with identical native pixels, full WRAM and VRAM. + Current-build save/load agrees in all RAM and pixels at both widths, and the + actual780-frame rocket/ascent handoff still passes with the VRAM policy. +- Serges canvas:30 samples over360 frames per width at342 and interpreter684 + show the complete front in the gutters with identical native pixels, full + WRAM and VRAM. Current-build save/load agrees in all RAM and pixels at both + widths and in the exposed phase. Destroyed cannons remain absent. Production + tests exercise sparse canvas selection, native mismatch rejection, live map + edits, native pixel preservation, fresh blank uploads and ordinary-room reset. +- The combined canvas policies retain all sampled342px presented pixels and + all128KiB WRAM in the accepted X3 Byte420-frame and factory360-frame replays. The original-ROM arena reference is valid. Unsuccessful original rocket and -Serges preparation attempts are excluded. The reported incomplete Serges -entrance artwork remains a failed owner observation pending a focused replay; -actor/OAM presence alone does not close it. Owner visual acceptance of the -rocket margins and background extension is also pending. Byte and doors are -already accepted and require no repeat owner handoff. +Serges preparation attempts are excluded. The owner accepted the rocket/ascent +visuals, Serges backdrop, Boss Rush black bars, exposed Serges weakpoint and +complete stage exit in round5. Byte, doors and factory are also accepted and +require no repeat owner handoff. The two follow-up visual fixes (Serges front +and Ostrich entrance bars) await focused owner confirmation. Remaining fortress +and final routes, the full content inventory, independent audio qualification +and current-main combined integration remain open. Nothing is merged here. Evidence: `G:/Temp/Codex/mmx23-trigger-audit-20261026/burndown`. diff --git a/runner/src/snes/mmx23_background.c b/runner/src/snes/mmx23_background.c index 84e5608a..fa2362af 100644 --- a/runner/src/snes/mmx23_background.c +++ b/runner/src/snes/mmx23_background.c @@ -14,6 +14,7 @@ static Snes *s_snes; static Ppu *s_ppu; static uint8_t *s_ram; static uint16_t s_bg1_repeat_right; +static bool s_bg2_source_canvas; /* A frame-local memo avoids decoding the same metatile on all eight raster * lines. Its size is independent of world height; collisions re-resolve. */ static uint32_t s_tile_keys[4096]; @@ -93,9 +94,10 @@ static bool stream_valid(const MmxBgStream *s, int32_t wx, int32_t wy) { return false; /* BG1 is the terrain stream, including deliberately empty sky during the * rocket ascent. Rejecting its uniform native window exposes old circular - * VRAM columns in the gutters. Only BG2 is repurposed as a moving object - * layer and needs the diversity safeguard below. */ - if (s == &kBgStreams[0]) + * VRAM columns in the gutters. BG2 normally needs the object-layer + * safeguard below; a title-selected loaded canvas uses its own source + * even before its leading edge reaches the native view. */ + if (s == &kBgStreams[0] || s_bg2_source_canvas) return true; /* Diversity: a near-uniform native sample proves nothing — some scenes * repurpose BG2 as a mostly-transparent OBJECT layer (X-Hunter tower @@ -198,6 +200,13 @@ static WsShadowSource world_provider(void *context, uint32_t tx, uint32_t ty, uint16_t *entry) { const MmxBgStream *stream = context; if (tx >= 1024 || ty >= 1024) return kWsShadowMiss; + if (stream == &kBgStreams[1] && s_bg2_source_canvas) { + /* The original map streamer uploads only the approaching native edge. + * Its fresh blank entries must not hide the already-loaded object art + * in a gutter. Map edits (including destroyed parts) remain live. */ + *entry = source_tile(stream, tx, ty); + return kWsShadowMarginOverride; + } if (stream == &kBgStreams[0] && s_bg1_repeat_right && tx >= (uint32_t)s_bg1_repeat_right / 8) { uint32_t boundary = (uint32_t)s_bg1_repeat_right / 8; @@ -226,7 +235,8 @@ static WsShadowSource world_provider(void *context, uint32_t tx, uint32_t ty, } void Mmx23ConfigureWsBgMarginsForRoom(Snes *snes, Ppu *ppu, uint8_t *ram, - bool enabled, uint16_t bg1RepeatRight) { + bool enabled, uint16_t bg1RepeatRight, + bool bg2SourceCanvas) { if (!ppu) return; static int on = -1, debug = -1; if (on < 0) { @@ -234,6 +244,7 @@ void Mmx23ConfigureWsBgMarginsForRoom(Snes *snes, Ppu *ppu, uint8_t *ram, e=getenv("SNESRECOMP_WS_BG_MARGINS_DEBUG"); debug=e && e[0]!='0'; } s_snes=snes; s_ppu=ppu; s_ram=ram; + s_bg2_source_canvas = bg2SourceCanvas; s_bg1_repeat_right = bg1RepeatRight >= 256 && !(bg1RepeatRight & 7) ? bg1RepeatRight : 0; unsigned mode=ppu->bgmode & 0x37; @@ -278,5 +289,5 @@ void Mmx23ConfigureWsBgMarginsForRoom(Snes *snes, Ppu *ppu, uint8_t *ram, } void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { - Mmx23ConfigureWsBgMarginsForRoom(snes,ppu,ram,enabled,0); + Mmx23ConfigureWsBgMarginsForRoom(snes,ppu,ram,enabled,0,false); } diff --git a/runner/src/snes/mmx23_background.h b/runner/src/snes/mmx23_background.h index d238b118..3e0c5208 100644 --- a/runner/src/snes/mmx23_background.h +++ b/runner/src/snes/mmx23_background.h @@ -7,8 +7,11 @@ struct Ppu; void Mmx23ConfigureWsBgMargins(struct Snes *snes, struct Ppu *ppu, uint8_t *ram, bool enabled); /* Optional authored right boundary: repeat the preceding 256px of BG1 into - * the right gutter only. Zero disables this room-specific presentation. */ + * the right gutter only. Zero disables this room-specific presentation. + * bg2SourceCanvas selects a known loaded object canvas: native validation + * still applies, but an empty native view does not reject offscreen art. */ void Mmx23ConfigureWsBgMarginsForRoom(struct Snes *snes, struct Ppu *ppu, uint8_t *ram, bool enabled, - uint16_t bg1RepeatRight); + uint16_t bg1RepeatRight, + bool bg2SourceCanvas); #endif diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index e3f0729f..4577ab88 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -245,6 +245,15 @@ void WsShadowSetWorldProvider(int layerIndex, WsShadowWorldProvider provider, s_layers[layerIndex].providerNativeOverrides = provider && nativeOverrides; } +void WsShadowUseVram(int layerIndex) { + if (layerIndex < 0 || layerIndex >= kLayers) return; + WsShadowLayer *layer = &s_layers[layerIndex]; + layer->active = layer->registered = layer->worldSet = layer->fold = false; + layer->provider = NULL; + layer->providerContext = NULL; + layer->providerNativeOverrides = false; +} + void WsShadowSetScroll(int layerIndex, uint32_t scrollX, uint32_t scrollY) { if (layerIndex < 0 || layerIndex >= kLayers) return; diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index 2dbdfe50..08d146b1 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -22,6 +22,10 @@ void WsShadowReset(void); * For strip-streaming games (Metal Warriors), pass the WRAM camera and also * call WsShadowSetScroll with the PPU buffer scroll. */ void WsShadowSetWorld(int layer, uint32_t worldX, uint32_t worldY); +/* Per-frame opt-out for a complete hardware canvas (for example a static + * parallax texture). Render the actual circular VRAM map in the gutters. + * The next SetWorld/Frame registration restores world-source lookup. */ +void WsShadowUseVram(int layer); /* Exact, uncached map source for a streaming title. Register after SetWorld * each frame. Queries use the rendered scanline's scroll phase and map-width diff --git a/tests/ppu/mmx23_stream_test.c b/tests/ppu/mmx23_stream_test.c index 535fc115..9bf8f3f9 100644 --- a/tests/ppu/mmx23_stream_test.c +++ b/tests/ppu/mmx23_stream_test.c @@ -40,6 +40,29 @@ int main(void) { assert(WsShadowLayerActive(0)); assert(!WsShadowLayerActive(1)); assert(WsShadowTile(0,-40,10,512,0x501b,0xdead)==0x0c00); assert(WsShadowTile(0,100,10,512,0x5000,0x1234)==0x1234); + /* A known loaded moving canvas can be empty in the native view while + * its leading edge is already visible in a gutter. Keep its source art, + * including fresh map edits, without changing native pixels. */ + ram[0xec01]=1; + put(ram,0xa800,1); + for (unsigned q=0;q<4;q++) put(rom,0x8008+q*2,0x1234); + Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0,true); + assert(WsShadowLayerActive(1)); + snes_frame_counter=10; WsShadowOnVramWrite(0x5c00,0); + assert(WsShadowTile(1,256,0,0,0x5c00,0)==0x1234); + assert(WsShadowTile(1,100,0,0,0x5800,0x9876)==0x9876); + for (unsigned q=0;q<4;q++) put(rom,0x8010+q*2,0x4321); + put(ram,0xa800,2); /* a live map edit replaces the destroyed part */ + Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0,true); + assert(WsShadowTile(1,256,0,0,0x5c00,0)==0x4321); + p.vram[vram_word(&kBgStreams[1],10,12)]=0xdead; + p.vram[vram_word(&kBgStreams[1],30,48)]=0xdead; + Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0,true); + assert(!WsShadowLayerActive(1)); + p.vram[vram_word(&kBgStreams[1],10,12)]=0x0c00; + p.vram[vram_word(&kBgStreams[1],30,48)]=0x0c00; + Mmx23ConfigureWsBgMargins(&snes,&p,ram,true); + assert(!s_bg2_source_canvas && !WsShadowLayerActive(1)); /* A genuinely different foreground still fails native self-validation. */ p.vram[vram_word(&kBgStreams[0],7690,4118)]=0x1234; p.vram[vram_word(&kBgStreams[0],7710,4154)]=0x1234; @@ -94,7 +117,7 @@ int main(void) { p.hScroll[0]=0x280; p.vScroll[0]=0x100; for (unsigned y=0;y<224;y+=8) for (unsigned x=0;x<256;x+=8) p.vram[vram_word(&kBgStreams[0],0xa80+x,0x100+y)]=world_tile(&kBgStreams[0],0xa80+x,0x100+y); - Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0xb80); + Mmx23ConfigureWsBgMarginsForRoom(&snes,&p,ram,true,0xb80,false); assert(WsShadowLayerActive(0)); assert(world_tile(&kBgStreams[0],0xb80,0x100)==0x0c00); snes_frame_counter=100; diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index 972340a6..1b92ff96 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -83,6 +83,17 @@ int main(void) { WsShadowOnVramWrite(0x5400,0xabcd); assert(WsShadowTile(0,256,0x200,0,0x5400,0)!=0xabcd); result=kWsShadowExact; + WsShadowUseVram(0); + assert(!WsShadowLayerActive(0)); + assert(WsShadowTile(0,256,0x200,0,0x5400,0x9876)==0x9876); + WsShadowSetWorld(0,0,0x200); + WsShadowSetWorldProvider(0,provider,&p,false); + WsShadowSetRespectGameWrites(0,0); + WsShadowFrame(&p); + assert(WsShadowLayerActive(0)); + calls=0; + got=WsShadowTile(0,256,0x200,0,0x5400,0x9876); + assert(calls==1 && got==(uint16_t)(0x2000|((last_x+last_y*3)&1023))); puts("exact world provider: high-Y, signed raster phase, native pixels, dynamic writes and lifecycle passed"); return 0; } From c1a336d782e9652572f05a49dc9e0c9b628d6591 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 08:09:03 -0700 Subject: [PATCH 08/17] fix(ppu): contain widescreen canvases within authored rooms --- runner/src/debug_server.c | 18 ++++-- runner/src/snes/ppu.c | 72 ++++++++++++++++++------ runner/src/snes/ppu.h | 16 ++++++ tests/ppu/ppu_world_mirror_test.c | 93 +++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 22 deletions(-) diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 69e15737..482dc1e9 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -5362,9 +5362,11 @@ static void cmd_get_ppu_state(const char *args) { "\"windowExpandLayers\":\"0x%02x\",\"windowExpandWindows\":\"0x%02x\"," "\"worldBands\":[" "{\"slot\":0,\"y0\":[%u,%u,%u,%u],\"y1\":[%u,%u,%u,%u]," - "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]}," + "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]," + "\"clipMask\":%u,\"reference\":[%u,%u,%u,%u]}," "{\"slot\":1,\"y0\":[%u,%u,%u,%u],\"y1\":[%u,%u,%u,%u]," - "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]}]," + "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]," + "\"clipMask\":%u,\"reference\":[%u,%u,%u,%u]}]," "\"elasticBands\":%s}," "\"evenFrame\":%s}", p->inidisp, p->bgmode & 7, p->mosaic, p->obsel, @@ -5384,9 +5386,9 @@ static void cmd_get_ppu_state(const char *args) { p->extraBottomCur, p->wsLayerWidenMask, p->wsLayerClamp, p->wsLayerMirror, p->wsLayerRepeat, p->wsWindowExpandLayers, p->wsWindowExpandWindows, - /* worldBands: per-layer authored-world spans for the - * world-mirror policy (PpuSetWidescreenLayerWorldMirrorBand). - * y1<=y0 or right<=left on a layer means that slot is off. */ + /* worldBands: authored-world mirror/clip spans. reference=0 + * means own scroll; 1..4 selects BG1..BG4. y1<=y0 or + * right<=left on a layer means that slot is off. */ p->wsWorldY0[0][0], p->wsWorldY0[0][1], p->wsWorldY0[0][2], p->wsWorldY0[0][3], p->wsWorldY1[0][0], p->wsWorldY1[0][1], p->wsWorldY1[0][2], @@ -5395,6 +5397,9 @@ static void cmd_get_ppu_state(const char *args) { p->wsWorldLeft[0][2], p->wsWorldLeft[0][3], p->wsWorldRight[0][0], p->wsWorldRight[0][1], p->wsWorldRight[0][2], p->wsWorldRight[0][3], + p->wsWorldClip[0], p->wsWorldReference[0][0], + p->wsWorldReference[0][1], p->wsWorldReference[0][2], + p->wsWorldReference[0][3], p->wsWorldY0[1][0], p->wsWorldY0[1][1], p->wsWorldY0[1][2], p->wsWorldY0[1][3], p->wsWorldY1[1][0], p->wsWorldY1[1][1], p->wsWorldY1[1][2], @@ -5403,6 +5408,9 @@ static void cmd_get_ppu_state(const char *args) { p->wsWorldLeft[1][2], p->wsWorldLeft[1][3], p->wsWorldRight[1][0], p->wsWorldRight[1][1], p->wsWorldRight[1][2], p->wsWorldRight[1][3], + p->wsWorldClip[1], p->wsWorldReference[1][0], + p->wsWorldReference[1][1], p->wsWorldReference[1][2], + p->wsWorldReference[1][3], eb, p->evenFrame ? "true" : "false"); } diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 8a23feae..349cbf37 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -298,6 +298,8 @@ static inline void PpuResetLayerPolicies(Ppu *ppu) { memset(ppu->wsWorldY1, 0, sizeof(ppu->wsWorldY1)); memset(ppu->wsWorldLeft, 0, sizeof(ppu->wsWorldLeft)); memset(ppu->wsWorldRight, 0, sizeof(ppu->wsWorldRight)); + memset(ppu->wsWorldClip, 0, sizeof(ppu->wsWorldClip)); + memset(ppu->wsWorldReference, 0, sizeof(ppu->wsWorldReference)); memset(ppu->wsElasticY0, 0, sizeof(ppu->wsElasticY0)); memset(ppu->wsElasticY1, 0, sizeof(ppu->wsElasticY1)); memset(ppu->wsElasticNSeg, 0, sizeof(ppu->wsElasticNSeg)); @@ -548,6 +550,29 @@ void PpuSetWidescreenLayerWorldMirrorBandSlot( ppu->wsWorldY1[slot][layer] = y1; ppu->wsWorldLeft[slot][layer] = world_left_px; ppu->wsWorldRight[slot][layer] = world_right_px; + ppu->wsWorldClip[slot] &= (uint8_t)~(1u << layer); + ppu->wsWorldReference[slot][layer] = 0; +} + +void PpuSetWidescreenLayerWorldClipBand( + Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, + uint16_t world_left_px, uint16_t world_right_px) { + PpuSetWidescreenLayerWorldClipBandSlot(ppu, 0, layer, y0, y1, + reference_layer, world_left_px, + world_right_px); +} + +void PpuSetWidescreenLayerWorldClipBandSlot( + Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, + uint8_t reference_layer, uint16_t world_left_px, uint16_t world_right_px) { + if (reference_layer >= 4) y0 = y1 = 0; + PpuSetWidescreenLayerWorldMirrorBandSlot(ppu, slot, layer, y0, y1, + world_left_px, world_right_px); + if (slot < kPpuWsWorldBands && layer < 4 && + ppu->wsWorldY1[slot][layer] > ppu->wsWorldY0[slot][layer]) { + ppu->wsWorldClip[slot] |= (uint8_t)(1u << layer); + ppu->wsWorldReference[slot][layer] = (uint8_t)(reference_layer + 1); + } } static void PpuClearElasticBandSlot(Ppu *ppu, uint8_t slot, uint8_t layer) { @@ -1848,16 +1873,20 @@ static void PpuMergePaddedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, } } -// Merge one isolated layer into the live priority buffer, reflecting columns -// that fall outside the layer's AUTHORED WORLD back inside it. Unlike -// PpuMergePaddedBackground this keeps every column the tilemap really -// authors -- including margin columns -- and only synthesizes where the world -// runs out. See PpuSetWidescreenLayerWorldMirrorBand. -static void PpuMergeWorldMirroredBackground(Ppu *ppu, +// Reference0 means the original, own-plane phase for existing world bands. +static unsigned PpuWorldReferenceLayer(const Ppu *ppu, int band, uint layer) { + unsigned reference = ppu->wsWorldReference[band][layer]; + return reference >= 1 && reference <= 4 ? reference - 1 : layer; +} + +// Preserve authored room columns, including genuine margin art. Outside the +// room, either reflect the layer's own art or omit a bounded object canvas. +// A canvas can follow another plane's PPU phase without sharing its scroll. +static void PpuMergeWorldBoundedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, const PpuPixelPrioBufs *layerbuf, int world_left, int world_right, - int hscroll) { + int hscroll, bool clip) { PpuZbufType *dst = dstbuf->data; const PpuZbufType *src = layerbuf->data; const int left_extra = ppu->extraLeftCur; @@ -1868,12 +1897,19 @@ static void PpuMergeWorldMirroredBackground(Ppu *ppu, const int b = world_right - hscroll; for (int x = -left_extra; x < kPpuXPixels + right_extra; x++) { int sx = x; - if (x < a) - sx = 2 * a - 1 - x; - else if (x >= b) - sx = 2 * b - 1 - x; - if (sx < a || sx >= b) - continue; // the reflection lands outside the world as well + if (clip) { + // The game may expose a few native edge pixels during a camera shake. + // Keep its authentic picture even then; contain only synthetic margins. + if ((x < 0 || x >= kPpuXPixels) && (x < a || x >= b)) + continue; + } else { + if (x < a) + sx = 2 * a - 1 - x; + else if (x >= b) + sx = 2 * b - 1 - x; + if (sx < a || sx >= b) + continue; // the reflection lands outside the world as well + } if (sx < -left_extra || sx >= kPpuXPixels + right_extra) continue; // that source column was never rendered this line int di = x + kPpuExtraLeftRight, si = sx + kPpuExtraLeftRight; @@ -1981,10 +2017,11 @@ static void PpuDrawBackground_4bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, } else if (world_band >= 0) { // A live world band implies the layer widens on this line, so no clamp, // mirror, repeat or stretch policy can be in force at the same time. - PpuMergeWorldMirroredBackground(ppu, dstbuf, &layerbuf, + PpuMergeWorldBoundedBackground(ppu, dstbuf, &layerbuf, ppu->wsWorldLeft[world_band][layer], ppu->wsWorldRight[world_band][layer], - ppu->hScroll[layer]); + ppu->hScroll[PpuWorldReferenceLayer(ppu,world_band,layer)], + (ppu->wsWorldClip[world_band] & (1u << layer)) != 0); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { @@ -2037,10 +2074,11 @@ static void PpuDrawBackground_2bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, } else if (world_band >= 0) { // A live world band implies the layer widens on this line, so no clamp, // mirror, repeat or stretch policy can be in force at the same time. - PpuMergeWorldMirroredBackground(ppu, dstbuf, &layerbuf, + PpuMergeWorldBoundedBackground(ppu, dstbuf, &layerbuf, ppu->wsWorldLeft[world_band][layer], ppu->wsWorldRight[world_band][layer], - ppu->hScroll[layer]); + ppu->hScroll[PpuWorldReferenceLayer(ppu,world_band,layer)], + (ppu->wsWorldClip[world_band] & (1u << layer)) != 0); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index 1fd58533..d19970df 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -290,6 +290,9 @@ struct Ppu { uint8_t wsWorldY0[kPpuWsWorldBands][4], wsWorldY1[kPpuWsWorldBands][4]; uint16_t wsWorldLeft[kPpuWsWorldBands][4]; uint16_t wsWorldRight[kPpuWsWorldBands][4]; + uint8_t wsWorldClip[kPpuWsWorldBands]; /* layer bits: clip instead of reflect */ + /* 0 keeps the layer's own phase; 1..4 selects BG1..BG4 as the reference. */ + uint8_t wsWorldReference[kPpuWsWorldBands][4]; // Piecewise horizontal remaps for the elastic anchor bands (see // PpuSetWidescreenLayerElasticBand). nseg == 0 or y1 <= y0 = slot off. uint8_t wsElasticY0[kPpuWsElasticBands][4]; @@ -929,6 +932,19 @@ void PpuSetWidescreenLayerWorldMirrorBandSlot( Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, uint16_t world_left_px, uint16_t world_right_px); +// Contain a Mode-1 object canvas within another scrolling plane's authored +// room. Only margin pixels outside [left,right), measured using the reference +// layer's actual PPU scroll on this line, are omitted. Native256 columns and +// genuine in-room margin art remain untouched. No canvas pixels are reflected. +// Reference/layer indices are0..3. Invalid inputs disable the slot. This is +// frame-local, outside save-state spans, and reset by PpuSetExtraSpace*. +void PpuSetWidescreenLayerWorldClipBand( + Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, + uint16_t world_left_px, uint16_t world_right_px); +void PpuSetWidescreenLayerWorldClipBandSlot( + Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, + uint8_t reference_layer, uint16_t world_left_px, uint16_t world_right_px); + // ELASTIC ANCHOR BANDS -- anchor a raster status bar's chrome to the widened // viewport edges and bridge the gap that opens by stretching only the // material that can survive being stretched. diff --git a/tests/ppu/ppu_world_mirror_test.c b/tests/ppu/ppu_world_mirror_test.c index 2863ae8e..bbe5adb4 100644 --- a/tests/ppu/ppu_world_mirror_test.c +++ b/tests/ppu/ppu_world_mirror_test.c @@ -219,6 +219,98 @@ static void check_camera(Ppu *ppu, int hscroll, const char *label, free(out); } +/* An independent-scrolling BG2/BG3 canvas must follow the physical room's + * BG1 phase for containment, while keeping all genuine canvas pixels. */ +static void render_clipped_canvas(Ppu *ppu, uint32_t *out, int layer, + bool big, bool mosaic, int reference_scroll, + int policy, int extra) { + memset(out, 0, sizeof(uint32_t) * kWide); + ppu_reset(ppu); + PpuBeginDrawing(ppu, (uint8_t *)out, sizeof(uint32_t) * kWide, + kPpuRenderFlags_NewRenderer); + setup_arena_bg1(ppu); + ppu->screenEnabled[0] = (uint8_t)(1u << layer); + ppu->bgXsc[layer] = ppu->bgXsc[0]; + ppu->bgTileAdr = (uint16_t)(1u << (layer * 4)); + if (big) ppu->bgmode |= (uint8_t)(0x10u << layer); + if (mosaic) ppu->mosaic = (uint8_t)(0x30u | (1u << layer)); + PpuSetExtraSpace(ppu, (uint16_t)extra); + ppu->hScroll[0] = 128; /* Change after configuring: use live PPU phase. */ + if (policy) { + PpuSetWidescreenLayerWorldClipBand(ppu, (uint8_t)layer, 0, 225, + policy == 5 ? 4 : 0, + kWorldLeft, kWorldRight); + if (policy == 3) + PpuSetWidescreenLayerWorldMirrorBand(ppu, (uint8_t)layer, 0, 225, + kWorldLeft, kWorldRight); + if (policy == 4) PpuSetExtraSpace(ppu, (uint16_t)extra); + } + if (layer == 2) PpuSetWidescreenBg3Widen(ppu, 1); + ppu->hScroll[0] = (uint16_t)reference_scroll; + ppu->hScroll[layer] = big ? 256 : 128; + ppu_runLine(ppu, 0); + ppu->hScroll[0] = (uint16_t)reference_scroll; + ppu->hScroll[layer] = big ? 256 : 128; + ppu_runLine(ppu, kLine); +} + +static void check_world_clip(Ppu *ppu) { + uint32_t raw[kWide], clipped[kWide]; + const int scrolls[] = {64, 128, 192, 61, 195}; + for (int layer = 1; layer <= 2; ++layer) { + for (int big = 0; big <= 1; ++big) { + for (int mosaic = 0; mosaic <= 1; ++mosaic) { + for (unsigned s = 0; s < sizeof(scrolls)/sizeof(scrolls[0]); ++s) { + int scroll = scrolls[s], outside = 0; + int kept_bad = 0, clipped_bad = 0; + char message[160]; + render_clipped_canvas(ppu, raw, layer, big, mosaic, + scroll, 0, kExtra); + render_clipped_canvas(ppu, clipped, layer, big, mosaic, + scroll, 1, kExtra); + for (int x = -kExtra; x < 256 + kExtra; ++x) { + int world = scroll + x, index = x + kExtra; + if ((x >= 0 && x < 256) || + (world >= kWorldLeft && world < kWorldRight)) { + kept_bad += raw[index] != clipped[index]; + } else { + clipped_bad += clipped[index] != 0; + outside += raw[index] != 0; + } + } + snprintf(message, sizeof message, + "clip layer%d big%d mosaic%d ref%d preserves native/in-room art", + layer, big, mosaic, scroll); + check(kept_bad == 0, message); + snprintf(message, sizeof message, + "clip layer%d big%d mosaic%d ref%d hides out-of-room copies", + layer, big, mosaic, scroll); + check(clipped_bad == 0, message); + if (scroll != 128) { + snprintf(message, sizeof message, + "clip layer%d big%d mosaic%d ref%d exercises nonblank overflow", + layer, big, mosaic, scroll); + check(outside > 0, message); + } + } + } + } + render_clipped_canvas(ppu, raw, layer, false, false, 61, 0, 0); + render_clipped_canvas(ppu, clipped, layer, false, false, 61, 1, 0); + check(memcmp(raw, clipped, sizeof raw) == 0, + "world clip is inert at native width, including camera shake"); + render_clipped_canvas(ppu, raw, layer, false, false, 64, 0, kExtra); + for (int policy = 3; policy <= 5; ++policy) { + render_clipped_canvas(ppu, clipped, layer, false, false, 64, + policy, kExtra); + check(memcmp(raw, clipped, sizeof raw) == 0, + policy == 3 ? "mirror setter clears previous canvas clip/reference" : + policy == 4 ? "per-frame reset removes canvas clip" : + "invalid reference disables canvas clip"); + } + } +} + int main(void) { Ppu *ppu = ppu_init(); if (!ppu) return 2; @@ -302,6 +394,7 @@ int main(void) { "slot 1 is independently addressable"); } + check_world_clip(ppu); ppu_free(ppu); printf("ppu_world_mirror_test: %s\n", failures ? "FAIL" : "PASS"); return failures ? 1 : 0; From 53d6ac94ac16cb04dcfd4ab39370e8853156cbb2 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 08:35:36 -0700 Subject: [PATCH 09/17] perf(ppu): draw bounded canvases through existing window spans --- runner/src/snes/ppu.c | 76 +++++++++++++++++++++++++++---- tests/ppu/ppu_world_mirror_test.c | 56 +++++++++++++++++++++-- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 349cbf37..1e8b891a 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -920,6 +920,65 @@ typedef struct PpuWindows { uint8 bits; } PpuWindows; +static unsigned PpuWorldReferenceLayer(const Ppu *ppu, int band, uint layer) { + unsigned reference = ppu->wsWorldReference[band][layer]; + return reference >= 1 && reference <= 4 ? reference - 1 : layer; +} + +// A clip does not resample pixels. When the room and native view touch, their +// union is one interval which the ordinary windowed draw can render directly. +// Keep the isolated compositor for disjoint rooms, mosaic and source anchors. +static bool PpuWorldClipBounds(const Ppu *ppu, uint layer, int y, + int *left, int *right) { + if (layer >= 4 || + !((ppu->wsWorldClip[0] | ppu->wsWorldClip[1]) & (1u << layer)) || + PPU_mode(ppu) != 1 || + (PPU_mosaicEnabled(ppu, layer) && PPU_mosaicSize(ppu) > 1)) + return false; + int band = PpuWidescreenLayerWorldBandAt(ppu, layer, y); + if (band < 0 || !(ppu->wsWorldClip[band] & (1u << layer)) || + PpuWidescreenLayerElasticBandAt(ppu, layer, y) >= 0) + return false; + for (uint slot = 0; slot < kPpuWsAnchorBands; ++slot) { + if (y >= ppu->wsAnchorY0[slot][layer] && + y < ppu->wsAnchorY1[slot][layer] && + ppu->wsAnchorLeftEnd[slot][layer] && + ppu->wsAnchorLeftEnd[slot][layer] < ppu->wsAnchorRightStart[slot][layer]) + return false; + } + int scroll = ppu->hScroll[PpuWorldReferenceLayer(ppu, band, layer)]; + int a = ppu->wsWorldLeft[band][layer] - scroll; + int b = ppu->wsWorldRight[band][layer] - scroll; + if (a > kPpuXPixels || b < 0) + return false; + *left = IntMin(a, 0); + *right = IntMax(b, kPpuXPixels); + return true; +} + +static void PpuWindows_ClipWorld(PpuWindows *win, const Ppu *ppu, + uint layer, int y) { + int left, right; + if (!PpuWorldClipBounds(ppu, layer, y, &left, &right)) + return; + // Crop after evaluating hardware windows so pinned edges and inverse masks + // retain their original meaning. Removing outer spans never adds an edge. + uint nr = 0; + uint8 bits = 0; + for (uint i = 0; i < win->nr; ++i) { + int a = IntMax(win->edges[i], left); + int b = IntMin(win->edges[i + 1], right); + if (a >= b) + continue; + win->edges[nr] = (int16)a; + win->edges[nr + 1] = (int16)b; + bits |= (uint8)(((win->bits >> i) & 1u) << nr); + ++nr; + } + win->nr = (uint8)nr; + win->bits = bits; +} + // Per-layer widescreen side margin. BG3 (layer 2) carries the HUD and is // clamped to the authentic 256-wide region so a BG3 status bar never tiles into // the margins -- EXCEPT on scanlines >= wsBg3WidenY, where the game renders @@ -1155,6 +1214,7 @@ static void PpuDrawBackground_4bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, return; // layer is completely hidden PpuWindows win; IS_SCREEN_WINDOWED(ppu, sub, layer) ? PpuWindows_Calc(&win, ppu, layer, y) : PpuWindows_Clear(&win, ppu, layer, y); + PpuWindows_ClipWorld(&win, ppu, layer, (int)y); int16 ws_bias[8] = { 0 }; if (!PpuApplyLayerAnchorBand(ppu, layer, y, &win, ws_bias)) PpuApplyMarginGap(ppu, layer, &win, ws_bias); @@ -1353,6 +1413,7 @@ static void PpuDrawBackgroundBig(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, PpuWindows win; IS_SCREEN_WINDOWED(ppu, sub, layer) ? PpuWindows_Calc(&win, ppu, layer, y) : PpuWindows_Clear(&win, ppu, layer, y); + PpuWindows_ClipWorld(&win, ppu, layer, (int)y); const int sy = (int)(mosaic ? ppu->mosaicModulo[y] : y) + ppu->vScroll[layer]; const int tileadr = PPU_bgTileAdr(ppu, layer); const int words = (bpp == 4) ? 16 : 8; /* vram words per 8x8 char */ @@ -1656,6 +1717,7 @@ static void PpuDrawBackground_2bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, b return; // layer is completely hidden PpuWindows win; IS_SCREEN_WINDOWED(ppu, sub, layer) ? PpuWindows_Calc(&win, ppu, layer, y) : PpuWindows_Clear(&win, ppu, layer, y); + PpuWindows_ClipWorld(&win, ppu, layer, (int)y); // Widescreen HUD split (PpuSetWidescreenHudSplit): on HUD scanlines, // replace the single span with five — left chunk re-anchored to the left // border edge, a transparent gap, the centered chunk, a gap, and the @@ -1873,12 +1935,6 @@ static void PpuMergePaddedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, } } -// Reference0 means the original, own-plane phase for existing world bands. -static unsigned PpuWorldReferenceLayer(const Ppu *ppu, int band, uint layer) { - unsigned reference = ppu->wsWorldReference[band][layer]; - return reference >= 1 && reference <= 4 ? reference - 1 : layer; -} - // Preserve authored room columns, including genuine margin art. Outside the // room, either reflect the layer's own art or omit a bounded object canvas. // A canvas can follow another plane's PPU phase without sharing its scroll. @@ -1986,8 +2042,10 @@ static void PpuDrawBackground_4bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, bool stretch_band = PpuWidescreenLayerStretchBandActive(ppu, layer, y); int world_band = PpuWidescreenLayerWorldBandAt(ppu, layer, (int)y); int elastic_band = PpuWidescreenLayerElasticBandAt(ppu, layer, (int)y); + int clip_left, clip_right; if (!(padding & (1u << layer)) && !repeat_band && !stretch_band && - world_band < 0 && elastic_band < 0) { + ((world_band < 0 && elastic_band < 0) || + PpuWorldClipBounds(ppu, layer, (int)y, &clip_left, &clip_right))) { if (PPU_bigTiles(ppu, layer)) PpuDrawBackgroundBig(ppu, dstbuf, y, sub, layer, 4, zhi, zlo, mosaic); else if (mosaic) @@ -2045,8 +2103,10 @@ static void PpuDrawBackground_2bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, bool stretch_band = PpuWidescreenLayerStretchBandActive(ppu, layer, y); int world_band = PpuWidescreenLayerWorldBandAt(ppu, layer, (int)y); int elastic_band = PpuWidescreenLayerElasticBandAt(ppu, layer, (int)y); + int clip_left, clip_right; if (!(padding & (1u << layer)) && !repeat_band && !stretch_band && - world_band < 0 && elastic_band < 0) { + ((world_band < 0 && elastic_band < 0) || + PpuWorldClipBounds(ppu, layer, (int)y, &clip_left, &clip_right))) { if (PPU_bigTiles(ppu, layer)) PpuDrawBackgroundBig(ppu, dstbuf, y, sub, layer, 2, zhi, zlo, mosaic); else if (mosaic) diff --git a/tests/ppu/ppu_world_mirror_test.c b/tests/ppu/ppu_world_mirror_test.c index bbe5adb4..443ba04e 100644 --- a/tests/ppu/ppu_world_mirror_test.c +++ b/tests/ppu/ppu_world_mirror_test.c @@ -221,15 +221,25 @@ static void check_camera(Ppu *ppu, int hscroll, const char *label, /* An independent-scrolling BG2/BG3 canvas must follow the physical room's * BG1 phase for containment, while keeping all genuine canvas pixels. */ -static void render_clipped_canvas(Ppu *ppu, uint32_t *out, int layer, +static void render_clipped_canvas_windows(Ppu *ppu, uint32_t *out, int layer, bool big, bool mosaic, int reference_scroll, - int policy, int extra) { + int policy, int extra, uint8_t windows, + int window_op) { memset(out, 0, sizeof(uint32_t) * kWide); ppu_reset(ppu); PpuBeginDrawing(ppu, (uint8_t *)out, sizeof(uint32_t) * kWide, kPpuRenderFlags_NewRenderer); setup_arena_bg1(ppu); ppu->screenEnabled[0] = (uint8_t)(1u << layer); + if (windows) { + ppu->screenWindowed[0] = (uint8_t)(1u << layer); + ppu->windowsel = (uint32_t)windows << (layer * 4); + ppu->window1left = 0; + ppu->window1right = 80; + ppu->window2left = 160; + ppu->window2right = 255; + ppu->wbgobjlog = (uint16_t)(window_op << (layer * 2)); + } ppu->bgXsc[layer] = ppu->bgXsc[0]; ppu->bgTileAdr = (uint16_t)(1u << (layer * 4)); if (big) ppu->bgmode |= (uint8_t)(0x10u << layer); @@ -244,6 +254,13 @@ static void render_clipped_canvas(Ppu *ppu, uint32_t *out, int layer, PpuSetWidescreenLayerWorldMirrorBand(ppu, (uint8_t)layer, 0, 225, kWorldLeft, kWorldRight); if (policy == 4) PpuSetExtraSpace(ppu, (uint16_t)extra); + if (policy == 6) { + PpuSetWidescreenLayerWorldMirrorBand(ppu, (uint8_t)layer, + 0, 0, 0, 0); + PpuSetWidescreenLayerWorldClipBandSlot(ppu, 1, (uint8_t)layer, + 0, 225, 0, + kWorldLeft, kWorldRight); + } } if (layer == 2) PpuSetWidescreenBg3Widen(ppu, 1); ppu->hScroll[0] = (uint16_t)reference_scroll; @@ -254,9 +271,16 @@ static void render_clipped_canvas(Ppu *ppu, uint32_t *out, int layer, ppu_runLine(ppu, kLine); } +static void render_clipped_canvas(Ppu *ppu, uint32_t *out, int layer, + bool big, bool mosaic, int reference_scroll, + int policy, int extra) { + render_clipped_canvas_windows(ppu, out, layer, big, mosaic, + reference_scroll, policy, extra, 0, 0); +} + static void check_world_clip(Ppu *ppu) { uint32_t raw[kWide], clipped[kWide]; - const int scrolls[] = {64, 128, 192, 61, 195}; + const int scrolls[] = {64, 128, 192, 61, 195, 512}; for (int layer = 1; layer <= 2; ++layer) { for (int big = 0; big <= 1; ++big) { for (int mosaic = 0; mosaic <= 1; ++mosaic) { @@ -299,6 +323,10 @@ static void check_world_clip(Ppu *ppu) { render_clipped_canvas(ppu, clipped, layer, false, false, 61, 1, 0); check(memcmp(raw, clipped, sizeof raw) == 0, "world clip is inert at native width, including camera shake"); + render_clipped_canvas(ppu, raw, layer, false, false, 64, 1, kExtra); + render_clipped_canvas(ppu, clipped, layer, false, false, 64, 6, kExtra); + check(memcmp(raw, clipped, sizeof raw) == 0, + "world clip second slot has the same rendered contract"); render_clipped_canvas(ppu, raw, layer, false, false, 64, 0, kExtra); for (int policy = 3; policy <= 5; ++policy) { render_clipped_canvas(ppu, clipped, layer, false, false, 64, @@ -308,6 +336,28 @@ static void check_world_clip(Ppu *ppu) { policy == 4 ? "per-frame reset removes canvas clip" : "invalid reference disables canvas clip"); } + const uint8_t window_flags[] = {2, 3, 8, 10, 15}; + for (unsigned w = 0; w < sizeof(window_flags); ++w) { + for (int op = 0; op < 4; ++op) { + for (int scroll = 61; scroll <= 195; scroll += 134) { + render_clipped_canvas_windows(ppu, raw, layer, false, + false, scroll, 0, kExtra, window_flags[w], op); + render_clipped_canvas_windows(ppu, clipped, layer, false, + false, scroll, 1, kExtra, window_flags[w], op); + int kept_bad = 0, clipped_bad = 0; + for (int x = -kExtra; x < 256 + kExtra; ++x) { + int world = scroll + x, index = x + kExtra; + if ((x >= 0 && x < 256) || + (world >= kWorldLeft && world < kWorldRight)) + kept_bad += raw[index] != clipped[index]; + else + clipped_bad += clipped[index] != 0; + } + check(kept_bad == 0 && clipped_bad == 0, + "world clip preserves hardware windows, inverse masks and pinned edges"); + } + } + } } } From 0095609940bc3a304da8c70f2dcab79264bbb8ab Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 08:51:36 -0700 Subject: [PATCH 10/17] test(dma): stub batched APU stepping in standalone harness --- tests/dma/hdma_timing_test.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/dma/hdma_timing_test.c b/tests/dma/hdma_timing_test.c index d48b8339..02f1d1e8 100644 --- a/tests/dma/hdma_timing_test.c +++ b/tests/dma/hdma_timing_test.c @@ -72,6 +72,9 @@ Apu *apu_init(void) { return NULL; } void apu_free(Apu *apu) { (void)apu; } void apu_reset(Apu *apu) { (void)apu; } void apu_cycle(Apu *apu) { (void)apu; } +/* Like the single-cycle stub, the batched entry is outside these DMA/RDNMI + * tests. Keep the standalone harness linkable with either snes.c caller. */ +void apu_runCycles(Apu *apu, uint32_t clocks) { (void)apu; (void)clocks; } void apu_saveload(Apu *apu, SaveLoadInfo *sli) { (void)apu; (void)sli; } Ppu *ppu_init(void) { return NULL; } From dec36f3710d8eb00149a19368efb106aa644c351 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 13:55:12 -0700 Subject: [PATCH 11/17] Support live VRAM margin filters for object canvases --- docs/WIDESCREEN_PATTERNS.md | 12 +++++++++++ runner/src/snes/ws_shadow.c | 24 ++++++++++++++++++++- runner/src/snes/ws_shadow.h | 9 ++++++++ tests/ppu/ws_world_provider_test.c | 34 ++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index 7fd371ca..e135fb6d 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -106,6 +106,18 @@ and must equal the reflected source column; mid-scroll the margin must equal what the tilemap authors there (i.e. must be byte-identical to the natural render), which is the assertion that catches a viewport-space fold. +Finite object canvases need a different source rule from scenery. X3's Maoh +paints its BG2 body over a reused circular map: neighboring map entries still +contain old level tiles. A complete map is not necessarily a complete authored +canvas. `WsShadowUseFilteredVram(layer, ppu, filter, context)`, registered after +`WsShadowFrame`, lets a title retain the renderer's current object entries and +omit known unrelated entries in the margins. It reads the exact live tile and +map address, bypasses terrain history, and leaves native and edge-straddling +chunks untouched. Registration expires next frame. Combine it with a world +clip if wrapping the map could repeat the object; supply skyline and terrain +from their own layers. Qualify the character range from the actual game data, +including moving/destroyed pieces, rather than guessing from a screenshot. + ### P2c. A continuous status bar anchors its rigid groups and stretches only elastic material **Defect:** `PpuSetWidescreenLayerAnchorBand` moves a raster status bar's left diff --git a/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 4577ab88..bf6cea5d 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -31,6 +31,8 @@ typedef struct WsShadowLayer { WsShadowWorldProvider provider; void *providerContext; bool providerNativeOverrides; + WsShadowVramMarginFilter vramMarginFilter; + void *vramMarginContext; uint16_t mapBaseWord; uint16_t *entries; uint8_t *valid; @@ -252,6 +254,20 @@ void WsShadowUseVram(int layerIndex) { layer->provider = NULL; layer->providerContext = NULL; layer->providerNativeOverrides = false; + layer->vramMarginFilter = NULL; + layer->vramMarginContext = NULL; +} + +void WsShadowUseFilteredVram(int layerIndex, const Ppu *ppu, + WsShadowVramMarginFilter filter, void *context) { + if (layerIndex < 0 || layerIndex >= kLayers) return; + WsShadowUseVram(layerIndex); + if (!ppu || !filter) return; + WsShadowLayer *layer = &s_layers[layerIndex]; + layer->tileShift = PPU_bigTiles(ppu, layerIndex) ? 4 : 3; + layer->vramMarginFilter = filter; + layer->vramMarginContext = context; + layer->active = true; } void WsShadowSetScroll(int layerIndex, uint32_t scrollX, uint32_t scrollY) { @@ -991,6 +1007,8 @@ static int EstimateOccMassDy(const uint8_t *prev_occ, int prev_cols, void WsShadowFrame(const struct Ppu *ppu) { for (int i = 0; i < kLayers; i++) { WsShadowLayer *layer = &s_layers[i]; + layer->vramMarginFilter = NULL; + layer->vramMarginContext = NULL; layer->active = layer->registered; layer->registered = false; if (!layer->active) @@ -1316,6 +1334,9 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, */ const int tile_pixels = 1 << (layer->tileShift ? layer->tileShift : 3); const bool native = screenX < 256 && screenX + tile_pixels > 0; + if (layer->vramMarginFilter) + return native ? realTile : layer->vramMarginFilter( + layer->vramMarginContext, mapWordAdr, realTile); if (!layer->active || (screenX >= 0 && screenX + tile_pixels <= 256 && !layer->providerNativeOverrides)) @@ -1426,7 +1447,8 @@ uint16_t WsShadowTile(int layerIndex, int screenX, uint32_t wrappedY, bool WsShadowLayerActive(int layerIndex) { return layerIndex >= 0 && layerIndex < kLayers && s_layers[layerIndex].active && - (s_layers[layerIndex].fold || s_layers[layerIndex].entries); + (s_layers[layerIndex].vramMarginFilter || + s_layers[layerIndex].fold || s_layers[layerIndex].entries); } uint32_t WsShadowWorldX(int layerIndex) { diff --git a/runner/src/snes/ws_shadow.h b/runner/src/snes/ws_shadow.h index 08d146b1..e5153aff 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -26,6 +26,15 @@ void WsShadowSetWorld(int layer, uint32_t worldX, uint32_t worldY); * parallax texture). Render the actual circular VRAM map in the gutters. * The next SetWorld/Frame registration restores world-source lookup. */ void WsShadowUseVram(int layer); +/* Frame-local object-canvas filter. Apply only to tile chunks wholly outside + * the native256 columns, using the exact tile/map address the renderer read. + * No history or world-source substitution; native/straddling chunks retain + * VRAM. Register after WsShadowFrame; the next frame or UseVram clears it. */ +struct Ppu; +typedef uint16_t (*WsShadowVramMarginFilter)(void *context, + uint16_t mapWordAdr, uint16_t realTile); +void WsShadowUseFilteredVram(int layer, const struct Ppu *ppu, + WsShadowVramMarginFilter filter, void *context); /* Exact, uncached map source for a streaming title. Register after SetWorld * each frame. Queries use the rendered scanline's scroll phase and map-width diff --git a/tests/ppu/ws_world_provider_test.c b/tests/ppu/ws_world_provider_test.c index 1b92ff96..bc84c4ce 100644 --- a/tests/ppu/ws_world_provider_test.c +++ b/tests/ppu/ws_world_provider_test.c @@ -10,6 +10,11 @@ int snes_frame_counter; static Ppu p; static unsigned calls, last_x, last_y; +static uint16_t vram_filter(void *context, uint16_t address, uint16_t tile) { + assert(context == &p && address == 0x5800); + ++calls; + return (tile & 1023) >= 0x300 ? tile : 0; +} static WsShadowSource result = kWsShadowExact; static WsShadowSource provider(void *context, uint32_t x, uint32_t y, uint16_t *entry) { assert(context == &p); @@ -94,6 +99,35 @@ int main(void) { calls=0; got=WsShadowTile(0,256,0x200,0,0x5400,0x9876); assert(calls==1 && got==(uint16_t)(0x2000|((last_x+last_y*3)&1023))); + /* An object-canvas filter reads current VRAM even if stale history or + * a world provider exists. Native and fine-scroll straddles stay exact. */ + for (int big=0;big<2;++big) { + p.bgmode=big ? 0x20 : 0; + WsShadowUseFilteredVram(1,&p,vram_filter,&p);calls=0; + assert(WsShadowLayerActive(1)); + assert(WsShadowTile(1,100,0,0,0x5800,0x1234)==0x1234); + assert(WsShadowTile(1,255,0,0,0x5800,0x1234)==0x1234); + assert(WsShadowTile(1,-1,0,0,0x5800,0x1234)==0x1234); + assert(WsShadowTile(1,big?-15:-7,0,0,0x5800,0x1234)==0x1234); + assert(calls==0); + assert(WsShadowTile(1,256,0,0,0x5800,0x7333)==0x7333); + assert(WsShadowTile(1,-16,0,0,0x5800,0x1234)==0); + assert(calls==2); + WsShadowFrame(&p);calls=0; + assert(!WsShadowLayerActive(1)); + assert(WsShadowTile(1,256,0,0,0x5800,0x1234)==0x1234 && calls==0); + } + WsShadowUseFilteredVram(1,&p,vram_filter,&p);WsShadowUseVram(1); + assert(!WsShadowLayerActive(1)); + WsShadowUseFilteredVram(1,&p,vram_filter,&p);WsShadowUseFilteredVram(1,NULL,vram_filter,&p); + assert(!WsShadowLayerActive(1)); + WsShadowUseFilteredVram(1,&p,NULL,&p);assert(!WsShadowLayerActive(1)); + setup(0,0x200);WsShadowForceTile(0,32,64,0xdead); + WsShadowUseFilteredVram(0,&p,vram_filter,&p);calls=0; + assert(WsShadowTile(0,256,0x200,0,0x5800,0x1234)==0 && calls==1); + WsShadowSetWorld(0,0,0x200);WsShadowSetWorldProvider(0,provider,&p,false); + WsShadowFrame(&p);calls=0; + assert(WsShadowTile(0,256,0x200,0,0x5400,0x1234)!=0 && calls==1); puts("exact world provider: high-Y, signed raster phase, native pixels, dynamic writes and lifecycle passed"); return 0; } From 05786e50bca11dff1f09f76c0e7ac9d48072c440 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 14:08:12 -0700 Subject: [PATCH 12/17] Repeat bounded room edge strips without copying interior damage --- docs/WIDESCREEN_PATTERNS.md | 8 ++++ runner/src/debug_server.c | 10 +++-- runner/src/snes/ppu.c | 35 +++++++++++++-- runner/src/snes/ppu.h | 13 ++++++ tests/ppu/ppu_world_mirror_test.c | 73 +++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index e135fb6d..ed608f1e 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -106,6 +106,14 @@ and must equal the reflected source column; mid-scroll the margin must equal what the tilemap authors there (i.e. must be byte-identical to the natural render), which is the assertion that catches a viewport-space fold. +Reflection also copies dynamic changes. If an arena can destroy its interior +floor, use `PpuSetWidescreenLayerWorldRepeatBand` to repeat a short undamaged +strip at each world edge instead. All authored room columns, including real +offscreen damage, and every native column remain unchanged. This keeps a +floor pit or entrance from being duplicated outside the room. Width0 disables +the band; larger widths are bounded by the authored span. As with reflection, +the source strip must be in the rendered line. + Finite object canvases need a different source rule from scenery. X3's Maoh paints its BG2 body over a reused circular map: neighboring map entries still contain old level tiles. A complete map is not necessarily a complete authored diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 482dc1e9..46259182 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -5363,10 +5363,10 @@ static void cmd_get_ppu_state(const char *args) { "\"worldBands\":[" "{\"slot\":0,\"y0\":[%u,%u,%u,%u],\"y1\":[%u,%u,%u,%u]," "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]," - "\"clipMask\":%u,\"reference\":[%u,%u,%u,%u]}," + "\"clipMask\":%u,\"repeatWidth\":[%u,%u,%u,%u],\"reference\":[%u,%u,%u,%u]}," "{\"slot\":1,\"y0\":[%u,%u,%u,%u],\"y1\":[%u,%u,%u,%u]," "\"left\":[%u,%u,%u,%u],\"right\":[%u,%u,%u,%u]," - "\"clipMask\":%u,\"reference\":[%u,%u,%u,%u]}]," + "\"clipMask\":%u,\"repeatWidth\":[%u,%u,%u,%u],\"reference\":[%u,%u,%u,%u]}]," "\"elasticBands\":%s}," "\"evenFrame\":%s}", p->inidisp, p->bgmode & 7, p->mosaic, p->obsel, @@ -5397,7 +5397,8 @@ static void cmd_get_ppu_state(const char *args) { p->wsWorldLeft[0][2], p->wsWorldLeft[0][3], p->wsWorldRight[0][0], p->wsWorldRight[0][1], p->wsWorldRight[0][2], p->wsWorldRight[0][3], - p->wsWorldClip[0], p->wsWorldReference[0][0], + p->wsWorldClip[0], p->wsWorldRepeat[0][0], p->wsWorldRepeat[0][1], + p->wsWorldRepeat[0][2], p->wsWorldRepeat[0][3], p->wsWorldReference[0][0], p->wsWorldReference[0][1], p->wsWorldReference[0][2], p->wsWorldReference[0][3], p->wsWorldY0[1][0], p->wsWorldY0[1][1], p->wsWorldY0[1][2], @@ -5408,7 +5409,8 @@ static void cmd_get_ppu_state(const char *args) { p->wsWorldLeft[1][2], p->wsWorldLeft[1][3], p->wsWorldRight[1][0], p->wsWorldRight[1][1], p->wsWorldRight[1][2], p->wsWorldRight[1][3], - p->wsWorldClip[1], p->wsWorldReference[1][0], + p->wsWorldClip[1], p->wsWorldRepeat[1][0], p->wsWorldRepeat[1][1], + p->wsWorldRepeat[1][2], p->wsWorldRepeat[1][3], p->wsWorldReference[1][0], p->wsWorldReference[1][1], p->wsWorldReference[1][2], p->wsWorldReference[1][3], eb, diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 1e8b891a..53114d4f 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -299,6 +299,7 @@ static inline void PpuResetLayerPolicies(Ppu *ppu) { memset(ppu->wsWorldLeft, 0, sizeof(ppu->wsWorldLeft)); memset(ppu->wsWorldRight, 0, sizeof(ppu->wsWorldRight)); memset(ppu->wsWorldClip, 0, sizeof(ppu->wsWorldClip)); + memset(ppu->wsWorldRepeat, 0, sizeof(ppu->wsWorldRepeat)); memset(ppu->wsWorldReference, 0, sizeof(ppu->wsWorldReference)); memset(ppu->wsElasticY0, 0, sizeof(ppu->wsElasticY0)); memset(ppu->wsElasticY1, 0, sizeof(ppu->wsElasticY1)); @@ -551,6 +552,7 @@ void PpuSetWidescreenLayerWorldMirrorBandSlot( ppu->wsWorldLeft[slot][layer] = world_left_px; ppu->wsWorldRight[slot][layer] = world_right_px; ppu->wsWorldClip[slot] &= (uint8_t)~(1u << layer); + ppu->wsWorldRepeat[slot][layer] = 0; ppu->wsWorldReference[slot][layer] = 0; } @@ -575,6 +577,25 @@ void PpuSetWidescreenLayerWorldClipBandSlot( } } +void PpuSetWidescreenLayerWorldRepeatBand( + Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, + uint16_t world_left_px, uint16_t world_right_px, uint16_t strip_width) { + PpuSetWidescreenLayerWorldRepeatBandSlot(ppu,0,layer,y0,y1, + world_left_px,world_right_px,strip_width); +} + +void PpuSetWidescreenLayerWorldRepeatBandSlot( + Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, + uint16_t world_left_px, uint16_t world_right_px, uint16_t strip_width) { + if (!strip_width) y0 = y1 = 0; + PpuSetWidescreenLayerWorldMirrorBandSlot(ppu,slot,layer,y0,y1, + world_left_px,world_right_px); + if (slot < kPpuWsWorldBands && layer < 4 && + ppu->wsWorldY1[slot][layer] > ppu->wsWorldY0[slot][layer]) + ppu->wsWorldRepeat[slot][layer] = (uint16_t)IntMin( + strip_width,world_right_px-world_left_px); +} + static void PpuClearElasticBandSlot(Ppu *ppu, uint8_t slot, uint8_t layer) { ppu->wsElasticY0[slot][layer] = 0; ppu->wsElasticY1[slot][layer] = 0; @@ -1942,7 +1963,7 @@ static void PpuMergeWorldBoundedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, const PpuPixelPrioBufs *layerbuf, int world_left, int world_right, - int hscroll, bool clip) { + int hscroll, bool clip, int strip) { PpuZbufType *dst = dstbuf->data; const PpuZbufType *src = layerbuf->data; const int left_extra = ppu->extraLeftCur; @@ -1958,6 +1979,12 @@ static void PpuMergeWorldBoundedBackground(Ppu *ppu, // Keep its authentic picture even then; contain only synthetic margins. if ((x < 0 || x >= kPpuXPixels) && (x < a || x >= b)) continue; + } else if (strip) { + // Never change authentic columns, even during an out-of-bounds shake. + if (x < 0 || x >= kPpuXPixels) { + if (x < a) sx = a + strip - 1 - ((a - x - 1) % strip); + else if (x >= b) sx = b - strip + ((x - b) % strip); + } } else { if (x < a) sx = 2 * a - 1 - x; @@ -2079,7 +2106,8 @@ static void PpuDrawBackground_4bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, ppu->wsWorldLeft[world_band][layer], ppu->wsWorldRight[world_band][layer], ppu->hScroll[PpuWorldReferenceLayer(ppu,world_band,layer)], - (ppu->wsWorldClip[world_band] & (1u << layer)) != 0); + (ppu->wsWorldClip[world_band] & (1u << layer)) != 0, + ppu->wsWorldRepeat[world_band][layer]); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { @@ -2138,7 +2166,8 @@ static void PpuDrawBackground_2bpp_policy(Ppu *ppu, PpuPixelPrioBufs *dstbuf, ppu->wsWorldLeft[world_band][layer], ppu->wsWorldRight[world_band][layer], ppu->hScroll[PpuWorldReferenceLayer(ppu,world_band,layer)], - (ppu->wsWorldClip[world_band] & (1u << layer)) != 0); + (ppu->wsWorldClip[world_band] & (1u << layer)) != 0, + ppu->wsWorldRepeat[world_band][layer]); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index d19970df..6316cc08 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -291,6 +291,7 @@ struct Ppu { uint16_t wsWorldLeft[kPpuWsWorldBands][4]; uint16_t wsWorldRight[kPpuWsWorldBands][4]; uint8_t wsWorldClip[kPpuWsWorldBands]; /* layer bits: clip instead of reflect */ + uint16_t wsWorldRepeat[kPpuWsWorldBands][4]; /* 0=reflect, otherwise edge strip */ /* 0 keeps the layer's own phase; 1..4 selects BG1..BG4 as the reference. */ uint8_t wsWorldReference[kPpuWsWorldBands][4]; // Piecewise horizontal remaps for the elastic anchor bands (see @@ -945,6 +946,18 @@ void PpuSetWidescreenLayerWorldClipBandSlot( Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, uint16_t world_left_px, uint16_t world_right_px); +// Preserve the authored world/native view and repeat only a short strip at +// either world edge into overflow margins. Useful when reflecting the room +// would duplicate dynamic damage or an entrance. Width is clamped to the +// world span; zero/invalid input disables the slot. Own per-line PPU phase. +// Frame-local, outside save-state spans, same precedence as a world mirror. +void PpuSetWidescreenLayerWorldRepeatBand( + Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, + uint16_t world_left_px, uint16_t world_right_px, uint16_t strip_width); +void PpuSetWidescreenLayerWorldRepeatBandSlot( + Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, + uint16_t world_left_px, uint16_t world_right_px, uint16_t strip_width); + // ELASTIC ANCHOR BANDS -- anchor a raster status bar's chrome to the widened // viewport edges and bridge the gap that opens by stretching only the // material that can survive being stretched. diff --git a/tests/ppu/ppu_world_mirror_test.c b/tests/ppu/ppu_world_mirror_test.c index 443ba04e..2447bc17 100644 --- a/tests/ppu/ppu_world_mirror_test.c +++ b/tests/ppu/ppu_world_mirror_test.c @@ -361,6 +361,78 @@ static void check_world_clip(Ppu *ppu) { } } +static void render_world_repeat(Ppu *ppu, uint32_t *out, int layer, + bool big, bool mosaic, int scroll, + int policy, int extra, int strip) { + const int width=256+extra*2; + memset(out,0,sizeof(uint32_t)*(size_t)width); + ppu_reset(ppu); + PpuBeginDrawing(ppu,(uint8_t *)out,sizeof(uint32_t)*(size_t)width, + kPpuRenderFlags_NewRenderer); + setup_arena_bg1(ppu); + /* A destroyed interior section must remain real, never copied outside + * the room when repeating its undamaged edge strips. */ + for (int y=0;y<32;++y) + for (int x=24;x<28;++x) ppu->vram[y*32+x]=0; + ppu->screenEnabled[0]=(uint8_t)(1u<bgXsc[layer]=ppu->bgXsc[0]; + ppu->bgTileAdr=(uint16_t)(1u<<(layer*4)); + if (big) ppu->bgmode|=(uint8_t)(0x10u<mosaic=(uint8_t)(0x30u|(1u<hScroll[layer]=(uint16_t)scroll;ppu_runLine(ppu,0); + ppu->hScroll[layer]=(uint16_t)scroll;ppu_runLine(ppu,kLine); +} + +static void check_world_repeat(Ppu *ppu) { + enum { extra=214,width=256+extra*2 }; + uint32_t raw[width],repeated[width],slot1[width]; + const int scrolls[]={64,128,192,61,195,512}; + const int strips[]={1,8,32,999}; + for (int layer=0;layer<=2;++layer) { + for (int big=0;big<=1;++big) for (int mosaic=0;mosaic<=1;++mosaic) + for (unsigned s=0;s=256) { + if (x=b) sx=b-strip+((x-b)%strip); + } + uint32_t expected=sx < -extra || sx>=256+extra?0:raw[sx+extra]; + bad+=repeated[x+extra]!=expected; + } + check(!bad,"world repeat preserves native/in-room damage and repeats only edge strips (4bpp/2bpp/big/mosaic/shake)"); + } + render_world_repeat(ppu,repeated,layer,false,false,64,1,extra,32); + render_world_repeat(ppu,slot1,layer,false,false,64,2,extra,32); + check(!memcmp(repeated,slot1,sizeof repeated),"world repeat second slot equivalence"); + render_world_repeat(ppu,raw,layer,false,false,61,0,extra,32); + for (int policy=3;policy<=7;++policy) { + render_world_repeat(ppu,repeated,layer,false,false,61,policy,extra,32); + check(!memcmp(raw,repeated,sizeof raw),"world repeat reset/replacement/invalid input clears mode"); + } + render_world_repeat(ppu,raw,layer,false,false,61,0,0,32); + render_world_repeat(ppu,repeated,layer,false,false,61,1,0,32); + check(!memcmp(raw,repeated,256*sizeof(uint32_t)),"world repeat native width/quake is inert"); + } +} + int main(void) { Ppu *ppu = ppu_init(); if (!ppu) return 2; @@ -445,6 +517,7 @@ int main(void) { } check_world_clip(ppu); + check_world_repeat(ppu); ppu_free(ppu); printf("ppu_world_mirror_test: %s\n", failures ? "FAIL" : "PASS"); return failures ? 1 : 0; From 819e2e1174335dc1c751e3bc6f0623e1438a42a2 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 16:08:34 -0700 Subject: [PATCH 13/17] Support bounded widescreen view anchoring without moving the guest camera --- docs/WIDESCREEN_PATTERNS.md | 17 + runner/src/debug_server.c | 17 +- runner/src/snes/ppu.c | 142 ++++++--- runner/src/snes/ppu.h | 64 ++++ runner/src/snes/ppu_legacy.c | 4 +- tests/ppu/ppu_view_anchor_test.c | 513 +++++++++++++++++++++++++++++++ tests/ppu/run.ps1 | 2 +- tests/run_c_tests.sh | 8 + 8 files changed, 725 insertions(+), 42 deletions(-) create mode 100644 tests/ppu/ppu_view_anchor_test.c diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index ed608f1e..489a95bc 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -212,6 +212,23 @@ destination run; and the gauge sweep -- every `k` from 0 to `n`, both gauges, within 1px of `round(k*dw/n)` of each other and of the ideal. `tests/ppu/ppu_elastic_band_test.c`. +### P2d. A view can slide inside room bounds without moving the guest camera + +`PpuSetWidescreenViewAnchor` and `PpuSetExtraSideSpace` let a consumer +allocate its total horizontal expansion to the open side of a room. The +physical frame origin follows the live margins. Native background and OBJ +coordinates stay unchanged; HUD groups keep their screen positions. Clear +padding when a room cannot fill the requested span. The policy resets each +frame and stays outside guest save-state spans. + +This mechanism reuses the accepted, parked X1 experiment `c3dc50a` with its +physical-centre HUD correction. The parked X1 worktrees remain untouched. +Consumers must validate their own camera bounds, streaming and spawn envelope; +this engine change does not enable anchoring for any title automatically. +`ppu_view_anchor_test` checks actual image placement, both edges, narrow rooms, +HUD, native/default-off output, reset and storage boundaries. One-sided spans +remain limited by the existing priority-buffer and ordinary 9-bit OBJ ranges. + ### P3. Periodic layers fold; world-anchored layers use history **Defect:** treating a horizontally periodic layer (sky, repeating city glow) as diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 46259182..13ac06e3 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -1550,8 +1550,12 @@ static char s_frange_dir[400]; typedef struct DebugPpuHostState { uint8_t *render_buffer; uint32_t render_pitch, render_flags; - uint8_t extra_left_cur, extra_right_cur, extra_left_right; + /* uint16_t, like the PPU's own fields: a uint8_t here silently truncated + * any budget past 255 px when an observer restored the host state. */ + uint16_t extra_left_cur, extra_right_cur, extra_left_right; uint8_t extra_bottom_cur; + uint8_t ws_view_anchor; + uint16_t extra_frame_left, extra_frame_right; PpuWidescreenLineEnhancer *enhancer; void *enhancer_context; } DebugPpuHostState; @@ -1564,6 +1568,9 @@ static void DebugPpuSaveHostState(Ppu *ppu, DebugPpuHostState *state) { state->extra_right_cur = ppu->extraRightCur; state->extra_left_right = ppu->extraLeftRight; state->extra_bottom_cur = ppu->extraBottomCur; + state->ws_view_anchor = ppu->wsViewAnchor; + state->extra_frame_left = ppu->extraFrameLeft; + state->extra_frame_right = ppu->extraFrameRight; state->enhancer = ppu->widescreenLineEnhancer; state->enhancer_context = ppu->widescreenLineEnhancerContext; } @@ -1577,6 +1584,9 @@ static void DebugPpuRestoreHostState(Ppu *ppu, ppu->extraRightCur = state->extra_right_cur; ppu->extraLeftRight = state->extra_left_right; ppu->extraBottomCur = state->extra_bottom_cur; + ppu->wsViewAnchor = state->ws_view_anchor; + ppu->extraFrameLeft = state->extra_frame_left; + ppu->extraFrameRight = state->extra_frame_right; PpuSetWidescreenLineEnhancer(ppu, state->enhancer, state->enhancer_context); } @@ -5357,6 +5367,7 @@ static void cmd_get_ppu_state(const char *args) { "\"cgramPointer\":\"0x%02x\"," "\"window1left\":%d,\"window1right\":%d,\"window2left\":%d,\"window2right\":%d," "\"widescreen\":{\"budget\":%u,\"left\":%u,\"right\":%u,\"bottom\":%u," + "\"viewAnchor\":%u,\"frameLeft\":%u,\"frameRight\":%u," "\"layerWiden\":\"0x%02x\",\"layerClamp\":\"0x%02x\"," "\"layerMirror\":\"0x%02x\",\"layerRepeat\":\"0x%02x\"," "\"windowExpandLayers\":\"0x%02x\",\"windowExpandWindows\":\"0x%02x\"," @@ -5383,7 +5394,9 @@ static void cmd_get_ppu_state(const char *args) { p->cgramPointer, p->window1left, p->window1right, p->window2left, p->window2right, p->extraLeftRight, p->extraLeftCur, p->extraRightCur, - p->extraBottomCur, p->wsLayerWidenMask, p->wsLayerClamp, + p->extraBottomCur, + p->wsViewAnchor, p->extraFrameLeft, p->extraFrameRight, + p->wsLayerWidenMask, p->wsLayerClamp, p->wsLayerMirror, p->wsLayerRepeat, p->wsWindowExpandLayers, p->wsWindowExpandWindows, /* worldBands: authored-world mirror/clip spans. reference=0 diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 53114d4f..6ed8a108 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -309,6 +309,7 @@ static inline void PpuResetLayerPolicies(Ppu *ppu) { memset(ppu->wsViewportInsetR, 0, sizeof(ppu->wsViewportInsetR)); ppu->wsWindowExpandLayers = 0; ppu->wsWindowExpandWindows = 0; + ppu->wsViewAnchor = 0; } int PpuWsExtraOverride(void) { @@ -331,6 +332,8 @@ void PpuSetExtraSpace(Ppu *ppu, uint16_t extra) { ppu->extraLeftRight = extra; ppu->extraLeftCur = extra; ppu->extraRightCur = extra; + ppu->extraFrameLeft = extra; + ppu->extraFrameRight = extra; PpuResetLayerPolicies(ppu); } @@ -344,6 +347,8 @@ void PpuSetExtraSpaceCentered(Ppu *ppu, uint16_t budget) { ppu->extraLeftRight = budget; ppu->extraLeftCur = 0; ppu->extraRightCur = 0; + ppu->extraFrameLeft = budget; + ppu->extraFrameRight = budget; PpuResetLayerPolicies(ppu); } @@ -353,11 +358,48 @@ void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom) { // the line renderer's window edges stay inside the priority buffers, bottom // clamps to the 16px overscan band. See ppu.h for the symmetric-vs-dynamic // distinction. - ppu->extraLeftCur = (uint16_t)IntMin(IntMax(left, 0), ppu->extraLeftRight); - ppu->extraRightCur = (uint16_t)IntMin(IntMax(right, 0), ppu->extraLeftRight); + const int budget = ppu->extraLeftRight; + if (ppu->wsViewAnchor) { + // Anchored (PpuSetWidescreenViewAnchor): the margins share ONE allowance + // of 2*budget columns rather than getting budget each, and the frame + // origin slides with them so a margin the game clamped to 0 becomes + // picture on the other side instead of a black bar on this one. + const int span = budget * 2; + const int cap = IntMin(span, kPpuExtraLeftRight); + left = IntMin(IntMax(left, 0), cap); + right = IntMin(IntMax(right, 0), cap); + if (right > span - left) + right = span - left; + // Whatever the two margins leave unclaimed is padding, split evenly; the + // odd column goes right, because the origin takes the floor. + int frame_left = left + (span - left - right) / 2; + // The priority buffers only hold kPpuExtraLeftRight columns either side of + // native x, so a budget past half of that cannot slide the whole way. + // Saturate and leave the remainder pillarboxed rather than addressing + // columns that do not exist. + frame_left = IntMin(IntMax(frame_left, span - kPpuExtraLeftRight), + kPpuExtraLeftRight); + ppu->extraLeftCur = (uint16_t)left; + ppu->extraRightCur = (uint16_t)right; + ppu->extraFrameLeft = (uint16_t)frame_left; + ppu->extraFrameRight = (uint16_t)(span - frame_left); + } else { + ppu->extraLeftCur = (uint16_t)IntMin(IntMax(left, 0), budget); + ppu->extraRightCur = (uint16_t)IntMin(IntMax(right, 0), budget); + ppu->extraFrameLeft = (uint16_t)budget; + ppu->extraFrameRight = (uint16_t)budget; + } ppu->extraBottomCur = (uint8_t)IntMin(IntMax(bottom, 0), 16); } +void PpuSetWidescreenViewAnchor(Ppu *ppu, bool enabled) { + ppu->wsViewAnchor = enabled ? 1 : 0; + // Re-derive the origin from the margins already published this frame, so a + // game may publish the anchor and the margins in either order. + PpuSetExtraSideSpace(ppu, ppu->extraLeftCur, ppu->extraRightCur, + ppu->extraBottomCur); +} + void PpuSetWidescreenHudSplit(Ppu *ppu, uint8_t height, uint8_t left_end, uint8_t right_start) { // See ppu.h. Chunk bounds must be ordered for the span construction in @@ -712,19 +754,24 @@ void ppu_handleVblank(Ppu* ppu) { } // The columns anything can reach this frame: the 256 hardware ones plus the -// per-side budget the host asked for. Every read of a priority buffer is -// bounded by a window edge, and PpuWidescreenLayerExtra never hands out more -// than extraLeftRight, so nothing outside this span is ever looked at. +// columns between native x=0 and each physical frame edge. Every read of a +// priority buffer is bounded by a window edge, and PpuWidescreenLayerExtra +// never hands out more than the live margin, so nothing outside this span is +// ever looked at. extraFrameLeft/extraFrameRight are the budget either side +// unless view anchoring slid the origin, in which case they are what the slide +// made them -- and they are never smaller than the live margins. // // Rounded out to whole uint64 stores, because that is how ClearBackdrop // writes. static inline void PpuActiveSpan(const Ppu *ppu, size_t *begin, size_t *end) { - size_t extra = ppu->extraLeftRight; - if (extra > kPpuExtraLeftRight) - extra = kPpuExtraLeftRight; - size_t b = ((size_t)kPpuExtraLeftRight - extra) & ~(size_t)3; - size_t e = ((size_t)kPpuExtraLeftRight + kPpuXPixels + extra + 3) & + size_t left = ppu->extraFrameLeft, right = ppu->extraFrameRight; + if (left > kPpuExtraLeftRight) + left = kPpuExtraLeftRight; + if (right > kPpuExtraLeftRight) + right = kPpuExtraLeftRight; + size_t b = ((size_t)kPpuExtraLeftRight - left) & ~(size_t)3; + size_t e = ((size_t)kPpuExtraLeftRight + kPpuXPixels + right + 3) & ~(size_t)3; if (e > kPpuBufWidth) e = kPpuBufWidth; @@ -1152,9 +1199,9 @@ static bool PpuApplyLayerAnchorBand(Ppu *ppu, uint layer, uint y, const bool full_budget = (ppu->wsHudSplitHeight & 0x80) && ppu->extraLeftRight; const int left_extra = - full_budget ? ppu->extraLeftRight : ppu->extraLeftCur; + full_budget ? ppu->extraFrameLeft : ppu->extraLeftCur; const int right_extra = - full_budget ? ppu->extraLeftRight : ppu->extraRightCur; + full_budget ? ppu->extraFrameRight : ppu->extraRightCur; if (!(left_extra | right_extra)) return false; win->nr = 5; @@ -1742,7 +1789,9 @@ static void PpuDrawBackground_2bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, b // Widescreen HUD split (PpuSetWidescreenHudSplit): on HUD scanlines, // replace the single span with five — left chunk re-anchored to the left // border edge, a transparent gap, the centered chunk, a gap, and the - // right chunk re-anchored to the right border edge. ws_bias shifts each + // right chunk re-anchored to the right border edge. Those edges are the + // PHYSICAL frame edges (extraFrameLeft/Right), which equal the budget + // unless view anchoring slid the origin. ws_bias shifts each // drawn span's source x so the chunks keep sampling their authentic // tilemap columns. Normally applied only when the computed window set is // one full drawn span: games (SMW) often enable screen-level window masking @@ -1758,14 +1807,16 @@ static void PpuDrawBackground_2bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, b (hud_always_visible || (win.nr == 1 && win.bits == 0))) { win.nr = 5; win.bits = 0x0A; // spans 1 and 3 are the gaps - win.edges[0] = -ppu->extraLeftRight; - win.edges[1] = ppu->wsHudLeftEnd - ppu->extraLeftRight; - win.edges[2] = ppu->wsHudLeftEnd; - win.edges[3] = ppu->wsHudRightStart; - win.edges[4] = ppu->wsHudRightStart + ppu->extraLeftRight; - win.edges[5] = 256 + ppu->extraLeftRight; - ws_bias[0] = ppu->extraLeftRight; - ws_bias[4] = -(int16)ppu->extraLeftRight; + win.edges[0] = -(int16)ppu->extraFrameLeft; + win.edges[1] = (int16)(ppu->wsHudLeftEnd - ppu->extraFrameLeft); + const int center_shift = (int)ppu->extraLeftRight - ppu->extraFrameLeft; + win.edges[2] = (int16)(ppu->wsHudLeftEnd + center_shift); + win.edges[3] = (int16)(ppu->wsHudRightStart + center_shift); + win.edges[4] = (int16)(ppu->wsHudRightStart + ppu->extraFrameRight); + win.edges[5] = (int16)(256 + ppu->extraFrameRight); + ws_bias[0] = (int16)ppu->extraFrameLeft; + ws_bias[2] = (int16)-center_shift; + ws_bias[4] = -(int16)ppu->extraFrameRight; } else { PpuApplyMarginGap(ppu, layer, &win, ws_bias); } @@ -1935,8 +1986,8 @@ static void PpuMergePaddedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, bool repeat, bool full_budget) { PpuZbufType *dst = dstbuf->data; const PpuZbufType *src = layerbuf->data; - int left_extra = full_budget ? ppu->extraLeftRight : ppu->extraLeftCur; - int right_extra = full_budget ? ppu->extraLeftRight : ppu->extraRightCur; + int left_extra = full_budget ? ppu->extraFrameLeft : ppu->extraLeftCur; + int right_extra = full_budget ? ppu->extraFrameRight : ppu->extraRightCur; for (int x = 0; x < kPpuXPixels; x++) { int i = x + kPpuExtraLeftRight; if (src[i] > dst[i]) dst[i] = src[i]; @@ -2044,8 +2095,8 @@ static void PpuMergeStretchedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, const PpuPixelPrioBufs *layerbuf) { PpuZbufType *dst = dstbuf->data; const PpuZbufType *src = layerbuf->data; - const int left_extra = ppu->extraLeftRight; - const int right_extra = ppu->extraLeftRight; + const int left_extra = ppu->extraFrameLeft; + const int right_extra = ppu->extraFrameRight; const int out_width = kPpuXPixels + left_extra + right_extra; if (out_width <= 0) return; @@ -2323,8 +2374,8 @@ static void PpuDrawSprites(Ppu *ppu, uint y, uint sub, bool clear_backdrop) { (ppu->wsHudOamSlots || ppu->wsHudOamSlots2) && ppu->extraLeftRight; if (oam_hud_full_width && win.nr == 1 && win.bits == 0) { - win.edges[0] = -(int16_t)ppu->extraLeftRight; - win.edges[1] = 256 + ppu->extraLeftRight; + win.edges[0] = -(int16_t)ppu->extraFrameLeft; + win.edges[1] = (int16_t)(256 + ppu->extraFrameRight); } for (size_t windex = 0; windex < win.nr; windex++) { if (win.bits & (1 << windex)) @@ -2558,6 +2609,8 @@ static NOINLINE void PpuDrawWholeLine(Ppu *ppu, uint y) { PpuClearOverlayRenderLine(ppu, y); if (PPU_forcedBlank(ppu)) { uint8 *dst = &ppu->renderBuffer[(y - 1) * ppu->renderPitch]; + // The whole row, so the origin does not matter: the frame is + // 256 + 2*extraLeftRight wide however view anchoring places native x=0. size_t n = sizeof(uint32) * (256 + ppu->extraLeftRight * 2); memset(dst, 0, n); return; @@ -2612,17 +2665,17 @@ static NOINLINE void PpuDrawWholeLine(Ppu *ppu, uint y) { bool compose_full_budget = false; if (any_hud_full_width) { PpuWindows_CalcWithExtra(&cwin, ppu, 5, y, - ppu->extraLeftRight, ppu->extraLeftRight); + ppu->extraFrameLeft, ppu->extraFrameRight); compose_full_budget = true; } else if (hud_height && y < hud_height && ppu->extraLeftRight && cwin.nr == 1 && cwin.bits == 0) { - cwin.edges[0] = -(int16_t)ppu->extraLeftRight; - cwin.edges[1] = 256 + ppu->extraLeftRight; + cwin.edges[0] = -(int16_t)ppu->extraFrameLeft; + cwin.edges[1] = (int16_t)(256 + ppu->extraFrameRight); compose_full_budget = true; } else if (PpuWidescreenLineRepeatBandActive(ppu, y) && ppu->extraLeftRight && cwin.nr == 1 && cwin.bits == 0) { - cwin.edges[0] = -(int16_t)ppu->extraLeftRight; - cwin.edges[1] = 256 + ppu->extraLeftRight; + cwin.edges[0] = -(int16_t)ppu->extraFrameLeft; + cwin.edges[1] = (int16_t)(256 + ppu->extraFrameRight); compose_full_budget = true; } static const uint8 kCwBitsMod[8] = { @@ -2634,7 +2687,9 @@ static NOINLINE void PpuDrawWholeLine(Ppu *ppu, uint y) { uint32 *dst = (uint32*)&ppu->renderBuffer[(y - 1) * ppu->renderPitch], *dst_org = dst; - dst += compose_full_budget ? 0 : (ppu->extraLeftRight - ppu->extraLeftCur); + // Frame column of native x is x + extraFrameLeft, and this row starts at the + // leftmost column the composition windows will actually visit. + dst += compose_full_budget ? 0 : (ppu->extraFrameLeft - ppu->extraLeftCur); uint32 hud_world_left = kPpuExtraLeftRight - ppu->extraLeftCur; uint32 hud_world_right = kPpuExtraLeftRight + 256 + ppu->extraRightCur; @@ -2879,20 +2934,31 @@ static bool PpuWidescreenHudOamSlot(Ppu *ppu, uint8_t index, uint8_t y) { static int PpuAdjustWidescreenHudOamX(Ppu *ppu, uint8_t index, uint8_t y, int x) { + // Full-budget HUD sprites ride the PHYSICAL frame edges, so they follow the + // sliding origin; the rest ride the live world margin, which at a fully + // anchored edge IS the frame edge anyway. const bool full_budget = (ppu->wsHudSplitHeight & 0x80) != 0; const int left_extra = - full_budget ? ppu->extraLeftRight : ppu->extraLeftCur; + full_budget ? ppu->extraFrameLeft : ppu->extraLeftCur; const int right_extra = - full_budget ? ppu->extraLeftRight : ppu->extraRightCur; + full_budget ? ppu->extraFrameRight : ppu->extraRightCur; if (PpuWidescreenHudOamSlot(ppu, index, y)) { if (x >= 0 && x < ppu->wsHudLeftEnd) x -= left_extra; else if (x >= ppu->wsHudRightStart && x < 256) x += right_extra; + else if (x >= 0 && x < 256) + x += (int)ppu->extraLeftRight - ppu->extraFrameLeft; } return x; } +/* OAM X is 9 bits, so the reachable span is 512 columns: raw >= 256 + + * extraRightCur decodes negative, which puts the deepest reachable left column + * at -(256 - extraRightCur). A frame whose LIVE margins total more than 256 + * therefore has a far-side sliver no sprite can be placed in -- nothing is + * mis-decoded, it is simply unaddressable. Only view anchoring can produce + * that, and only above a 128 px budget; see PpuSetWidescreenViewAnchor. */ static int PpuDecodeOamX(Ppu *ppu, uint8_t index) { int x = ppu->oam[index] & 0xff; x |= ((ppu->highOam[index >> 3] >> (index & 7)) & 1) << 8; @@ -3059,7 +3125,7 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { const int left_extra = PpuWidescreenHudOamSlot(ppu, index, y) && (ppu->wsHudSplitHeight & 0x80) - ? ppu->extraLeftRight + ? ppu->extraFrameLeft : ppu->extraLeftCur; if (PpuWidescreenOamLeftHintAllows(ppu, index, x, spriteSize, left_extra)) { @@ -3089,9 +3155,9 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { PpuWidescreenHudOamSlot(ppu, index, sprite_y) && (ppu->wsHudSplitHeight & 0x80); const int left_extra = - full_budget_hud ? ppu->extraLeftRight : ppu->extraLeftCur; + full_budget_hud ? ppu->extraFrameLeft : ppu->extraLeftCur; const int right_extra = - full_budget_hud ? ppu->extraLeftRight : ppu->extraRightCur; + full_budget_hud ? ppu->extraFrameRight : ppu->extraRightCur; x = PpuAdjustWidescreenHudOamX(ppu, index, sprite_y, x); if(PPU_objInterlace(ppu)) row = row * 2 + (ppu->evenFrame ? 0 : 1); int oam1 = ppu->oam[index + 1]; diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index 6316cc08..a6947836 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -242,6 +242,16 @@ struct Ppu { uint16_t extraLeftCur, extraRightCur, extraLeftRight; uint8_t extraBottomCur; + // Widescreen view anchoring (see PpuSetWidescreenViewAnchor). 0 = off, and + // off is what every existing consumer renders with. + uint8_t wsViewAnchor; + // Columns between native x=0 and the PHYSICAL left/right framebuffer edges, + // i.e. the frame column of native x is x + extraFrameLeft. They always sum + // to 2*extraLeftRight -- the frame is the same width either way, only its + // origin moves. With anchoring off both are extraLeftRight (the fixed + // centre origin); with it on they slide as the live margins reach the + // authored bounds. Host presentation only, like the margins themselves. + uint16_t extraFrameLeft, extraFrameRight; // Widescreen BG3 HUD split (see PpuSetWidescreenHudSplit). 0 height = off. uint8_t wsHudSplitHeight, wsHudLeftEnd, wsHudRightStart; // Widescreen HUD OAM anchor (see PpuSetWsHudOamShiftRange): an OAM slot @@ -710,6 +720,12 @@ void ppu_rb_residue_set(Ppu *ppu, const PpuRollbackResidue *in); // Replace stale BG1 tilemap pixels in widened side margins before final // composition. The callback is host-only and runs independently for main and // subscreen buffers. +// The callback writes PRIORITY-BUFFER columns (native x at +// data[x + kPpuExtraLeftRight]), not framebuffer columns, so the widened span +// it should fill is the LIVE margin -- extraLeftCur/extraRightCur -- and never +// the budget. An enhancer written that way needs no change under view +// anchoring; one that reads extraLeftRight as "how far the margin reaches" +// would under- or over-fill as soon as a game opts in. void PpuSetWidescreenLineEnhancer(Ppu *ppu, PpuWidescreenLineEnhancer *enhancer, void *context); @@ -766,6 +782,54 @@ void PpuSetExtraSpaceCentered(Ppu *ppu, uint16_t budget); // versus PpuSetExtraSpace's fixed symmetric border (SMW). void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom); +// VIEW ANCHORING (opt-in, off by default) -- let the presentation viewport +// SLIDE inside the authored bounds instead of pillarboxing at them. +// +// Without it the frame origin is fixed: native x=0 always lands at framebuffer +// column extraLeftRight, and PpuSetExtraSideSpace clamps each margin to that +// same budget. A game that clamps its margins to the level bounds (SMW, Zelda) +// therefore gets a black bar on the bounded side while the other side still +// shows only `budget` columns of world -- half the widescreen it paid for, and +// the half it lost is the half it could actually have filled. +// +// With anchoring on, the two margins share ONE allowance of 2*extraLeftRight +// columns and the origin follows them: +// at the level's left edge left=0, right=2*budget -- native x=0 sits at +// frame column 0 and the whole extra span is +// world on the right; +// at the right edge the mirror image; +// a level narrower than the the live span is centred and the leftover is +// frame padded equally either side (an odd remainder +// goes to the right, since extraFrameLeft takes +// the floor); +// mid-level left=right=budget, i.e. exactly the fixed +// centre origin, so nothing moves where nothing +// needs to. +// The guest camera is never touched: this moves only where the rendered +// columns are placed in the framebuffer, and which of them are live. +// +// Constraints: +// - Publish it AFTER PpuSetExtraSpace/PpuSetExtraSpaceCentered (those reset +// the widescreen policies) and it takes effect on the next +// PpuSetExtraSideSpace; toggling it re-derives the origin from the margins +// already published, so the two calls may come in either order per frame. +// - OBJ positions are 9 bits, so the reachable span is 512 columns wide: a +// sprite at native x is written raw x+512 when negative, and ppu.c decodes +// raw >= 256 + extraRightCur as negative. The left margin is therefore only +// reachable by sprites down to native x = -(256 - extraRightCur), which +// means the LIVE TOTAL extraLeftCur + extraRightCur must stay <= 256 for a +// sprite to be placeable anywhere in the frame. Anchoring can put the whole +// 2*budget allowance on one side, so that total is 2*budget: a budget above +// 128 px per side (an internal width above 512) leaves a far-margin sliver +// no sprite can reach. Backgrounds are unaffected, nothing is MIS-placed, +// and every shipping budget (43 px MMX, 71 px SMW 16:9) is far inside it, +// so this is documented rather than clamped -- clamping would throw away +// real background columns to spare a sprite artefact. +// - The priority buffers carry kPpuExtraLeftRight columns per side, so a +// budget past half of that cannot slide the whole way; the origin saturates +// and the remainder stays pillarboxed. +void PpuSetWidescreenViewAnchor(Ppu *ppu, bool enabled); + // Widescreen HUD split (opt-in, configured by the game frontend): for // scanlines < height, BG3 (layer 2) is drawn as three chunks — source // [0,left_end) anchored to the LEFT border edge, [left_end,right_start) diff --git a/runner/src/snes/ppu_legacy.c b/runner/src/snes/ppu_legacy.c index 3959f52d..6a4511fc 100644 --- a/runner/src/snes/ppu_legacy.c +++ b/runner/src/snes/ppu_legacy.c @@ -130,7 +130,9 @@ static void ppu_render_pixel(Ppu *ppu, int x, int y) { } } int row = y - 1; - uint8 *pixelBuffer = (uint8 *)&ppu->renderBuffer[row * ppu->renderPitch + (x + ppu->extraLeftRight) * 4]; + // Frame column of native x is x + extraFrameLeft (the budget, unless view + // anchoring slid the origin). + uint8 *pixelBuffer = (uint8 *)&ppu->renderBuffer[row * ppu->renderPitch + (x + ppu->extraFrameLeft) * 4]; pixelBuffer[0] = ((b << 3) | (b >> 2)) * PPU_brightness(ppu) / 15; pixelBuffer[1] = ((g << 3) | (g >> 2)) * PPU_brightness(ppu) / 15; pixelBuffer[2] = ((r << 3) | (r >> 2)) * PPU_brightness(ppu) / 15; diff --git a/tests/ppu/ppu_view_anchor_test.c b/tests/ppu/ppu_view_anchor_test.c new file mode 100644 index 00000000..cee542ca --- /dev/null +++ b/tests/ppu/ppu_view_anchor_test.c @@ -0,0 +1,513 @@ +/* Synthetic regression for PpuSetWidescreenViewAnchor + * (WIDESCREEN_PATTERNS P2d: a bounded level slides the frame inside its own + * bounds instead of pillarboxing at them). + * + * Without anchoring the frame origin is fixed -- native x=0 always lands at + * framebuffer column extraLeftRight -- so a game that clamps a margin to its + * level bound gets a black bar on that side and still only `budget` columns of + * world on the other. With anchoring the two margins share one 2*budget + * allowance and the origin follows them. + * + * The oracle is a REFERENCE render of the same scene at a symmetric 2*budget + * margin, where every native column an anchored frame can reach is present at + * a known place: what a column of the world looks like does not depend on how + * much margin is live, only on where it is put. Every assertion here is + * therefore of the form "frame column x + extraFrameLeft carries native + * column x", which is the whole invariant. + * + * The load-bearing assertions: + * 1. (L=0, R=2B) puts native x=0 at frame column 0 and world columns + * 256..256+2B-1 across the right margin -- the level's left edge; + * 2. (L=2B, R=0) is the mirror image, flush to the right physical edge; + * 3. (0,0) and any (a,b) with a+b < 2B centre the live span, padding equally + * either side (an odd remainder goes right, since the origin floors); + * 4. with anchoring OFF the origin is the fixed centre one for every + * (L<=B, R<=B), and the per-side clamp is unchanged -- the pre-change + * rule, which is the top acceptance criterion; + * 5. the split BG3 HUD anchors its outer chunks to the PHYSICAL frame edges + * and keeps its centre group at the physical frame centre; + * 6. budget 0 (authentic 256-wide output) is untouched either way; + * 7. the new storage lies outside both PPU savestate spans. + */ +#include +#include +#include +#include +#include + +#include "snes/ppu.h" +#include "snes/snes.h" + +Snes *g_snes; + +/* ppu.c reaches runtime globals defined in snes.c / common_rtl.c and the + * ws_shadow streaming-tilemap helpers. This harness links ppu.c on its own; + * none of them participate in view anchoring. */ +int snes_frame_counter; +unsigned char g_snesrecomp_last_hdmaen; + +uint16_t WsShadowTile(int layer, int screen_x, uint32_t wrapped_y, + uint16_t hscroll, uint16_t map_word_offset, + uint16_t real_tile) { + (void)layer; (void)screen_x; (void)wrapped_y; (void)hscroll; + (void)map_word_offset; + return real_tile; +} +bool WsShadowLayerActive(int layer) { (void)layer; return false; } +uint32_t WsShadowWorldX(int layer) { (void)layer; return 0; } +uint32_t WsShadowPresentWorldY(int layer, int screen_x) { + (void)layer; (void)screen_x; return 0; +} +uint32_t WsShadowScrollY(int layer) { (void)layer; return 0; } +void WsShadowOnVramWrite(uint16_t word_adr, uint16_t value) { + (void)word_adr; (void)value; +} + +enum { + kBudget = 43, /* per-side centring budget */ + kSpan = kBudget * 2, /* the anchored allowance, shared */ + kWide = kPpuXPixels + kSpan, /* 342: the 16:9 frame */ + kRefExtra = kSpan, /* oracle render: 2B live BOTH sides */ + kRefWide = kPpuXPixels + kRefExtra * 2, + kHudHeight = 16, + kHudLeftEnd = 48, + kHudRightStart = 208, + kScroll = 128, /* mid-map: no tilemap wrap either way */ + kHudLine = 2, /* row 1: inside the HUD band */ + kWorldLine = 20, /* row 19: below it */ + kRows = 24, + kMapBaseWords = 0x0000, /* BG1 tilemap, 64x64 */ + kBg3MapBaseWords = 0x0800, /* BG3 tilemap, 32x32 */ + kCharBaseWords = 0x1000, /* BG1 4bpp characters */ + kBg3CharBaseWords = 0x2000, /* BG3 2bpp characters */ + /* Spare columns after every row, so a deliberately inconsistent mutant + * cannot corrupt the heap before the assertion that catches it runs. */ + kSlack = 64, + kBg1 = 0x01, + kBg3 = 0x04, +}; + +static int failures; + +static void check(bool condition, const char *message) { + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message); + failures++; + } +} + +/* Write one 4bpp tile row. Pixel 0 is leftmost (plane bit 7), matching the + * hardware bit order the renderer decodes. */ +static void put_tile_row(Ppu *ppu, int tile, int row, const int *px) { + uint16_t w0 = 0, w1 = 0; + for (int p = 0; p < 8; p++) { + int v = px[p] & 0xf; + int bit = 7 - p; + w0 |= (uint16_t)(((v >> 0) & 1) << bit); + w0 |= (uint16_t)(((v >> 1) & 1) << (bit + 8)); + w1 |= (uint16_t)(((v >> 2) & 1) << bit); + w1 |= (uint16_t)(((v >> 3) & 1) << (bit + 8)); + } + ppu->vram[(kCharBaseWords + tile * 16 + row) & 0x7fff] = w0; + ppu->vram[(kCharBaseWords + tile * 16 + 8 + row) & 0x7fff] = w1; +} + +/* Write one 2bpp tile row: plane 0 in the low byte, plane 1 in the high one. */ +static void put_tile_row_2bpp(Ppu *ppu, int tile, int row, const int *px) { + uint16_t w = 0; + for (int p = 0; p < 8; p++) { + int v = px[p] & 3; + int bit = 7 - p; + w |= (uint16_t)(((v >> 0) & 1) << bit); + w |= (uint16_t)(((v >> 1) & 1) << (bit + 8)); + } + ppu->vram[(kBg3CharBaseWords + tile * 8 + row) & 0x7fff] = w; +} + +/* A Mode 1 scene whose every 8px column carries its own tile, so a rendered + * pixel identifies the world column it came from and a displaced frame cannot + * pass for a correctly placed one. BG1 is a 64x64 map (512px, wider than any + * span reachable at kScroll); BG3 is a 32x32 HUD plane pinned to hScroll 0. */ +static void setup_scene(Ppu *ppu, int layers) { + ppu->inidisp = 0x0f; + ppu->bgmode = 1; + ppu->screenEnabled[0] = (uint8_t)layers; + ppu->screenEnabled[1] = 0x00; + ppu->screenWindowed[0] = 0x00; + ppu->screenWindowed[1] = 0x00; + ppu->bgXsc[0] = 0x03; /* BG1 map at word 0, 64x64 */ + ppu->bgXsc[2] = 0x08; /* BG3 map at word 0x800, 32x32 */ + ppu->bgTileAdr = 0x0201; /* BG1 char 0x1000, BG3 char 0x2000 */ + ppu->vScroll[0] = 0; + ppu->vScroll[2] = 0; + ppu->hScroll[2] = 0; /* the HUD plane is pinned, as HUDs are */ + + memset(ppu->vram, 0, sizeof ppu->vram); + ppu->cgram[0] = 0x0000; + for (int i = 1; i < 256; i++) + ppu->cgram[i] = (uint16_t)(0x0421 * (i & 0x1f) + 0x1004 * (i >> 5)); + + /* 64 BG1 tile columns, tiles 1..64: a ramp shifted by the tile index. */ + for (int t = 1; t <= 64; t++) + for (int row = 0; row < 8; row++) { + int px[8]; + for (int p = 0; p < 8; p++) + px[p] = ((t * 3 + p * 5 + row) % 15) + 1; + put_tile_row(ppu, t, row, px); + } + for (int scr = 0; scr < 2; scr++) + for (int row = 0; row < 32; row++) + for (int col = 0; col < 32; col++) + ppu->vram[kMapBaseWords + scr * 0x400 + row * 32 + col] = + (uint16_t)(scr * 32 + col + 1); + + /* 32 BG3 tile columns, tiles 1..32, each with its own palette/colour mix + * so the HUD's three chunks are distinguishable from one another. */ + for (int t = 1; t <= 32; t++) + for (int row = 0; row < 8; row++) { + int px[8]; + for (int p = 0; p < 8; p++) + px[p] = ((t + p + row) % 3) + 1; + put_tile_row_2bpp(ppu, t, row, px); + } + for (int row = 0; row < 32; row++) + for (int col = 0; col < 32; col++) + ppu->vram[kBg3MapBaseWords + row * 32 + col] = + (uint16_t)((col + 1) | ((col & 7) << 10)); +} + +/* One render of one scanline. `anchor` < 0 means "publish no per-side margins + * at all" (the plain symmetric border); otherwise left/right are published with + * anchoring in the requested state. Rows are `width + kSlack` columns apart; + * the renderer must never touch the slack. Returns the rendered row. */ +static uint32_t *render(Ppu *ppu, uint32_t *buf, int width, int budget, + int anchor, int left, int right, int layers, + bool hud_split, int line) { + const size_t stride = (size_t)(width + kSlack); + memset(buf, 0, sizeof(uint32_t) * stride * kRows); + ppu_reset(ppu); + PpuBeginDrawing(ppu, (uint8_t *)buf, sizeof(uint32_t) * stride, + kPpuRenderFlags_NewRenderer); + setup_scene(ppu, layers); + PpuSetExtraSpace(ppu, (uint16_t)budget); /* resets policies: comes first */ + if (anchor >= 0) { + PpuSetWidescreenViewAnchor(ppu, anchor != 0); + PpuSetExtraSideSpace(ppu, left, right, 0); + } + if (hud_split) { + PpuSetWidescreenHudSplit(ppu, kHudHeight, kHudLeftEnd, kHudRightStart); + /* Always-visible is the configuration in which a split HUD is meant to + * occupy the physical framebuffer edges even where the game has + * clamped the world; without it the composite blacks the chunks that + * fall outside the live world span, which is the existing + * room-boundary pillarbox and not what this test is about. */ + PpuSetWidescreenHudAlwaysVisible(ppu, true); + } + ppu->hScroll[0] = kScroll; + ppu_runLine(ppu, 0); + ppu->hScroll[0] = kScroll; + ppu_runLine(ppu, line); + return buf + stride * (size_t)(line - 1); +} + +/* Nothing may be written past the frame's last column. */ +static void check_slack(const uint32_t *row, int width, const char *label) { + for (int i = 0; i < kSlack; i++) + if (row[width + i] != 0) { + char msg[160]; + snprintf(msg, sizeof msg, "%s: nothing written past the frame", + label); + check(false, msg); + return; + } +} + +/* Assert that a frame anchored with (left,right) places every live world + * column where the invariant says it does, and that whatever the live span + * does not claim is the equal black remainder. */ +static void check_case(Ppu *ppu, uint32_t *buf, const uint32_t *ref, int left, + int right, int expect_frame_left, const char *label) { + const uint32_t *out = + render(ppu, buf, kWide, kBudget, 1, left, right, kBg1, false, + kWorldLine); + char msg[160]; + + snprintf(msg, sizeof msg, "%s: live margins survive as asked", label); + check(ppu->extraLeftCur == left && ppu->extraRightCur == right, msg); + snprintf(msg, sizeof msg, "%s: frame origin is left + pad/2", label); + check(ppu->extraFrameLeft == expect_frame_left, msg); + snprintf(msg, sizeof msg, + "%s: extraFrameLeft + extraFrameRight == 2*budget", label); + check(ppu->extraFrameLeft + ppu->extraFrameRight == kSpan, msg); + + const int fl = (int)ppu->extraFrameLeft; + int world_mismatch = 0, pad_mismatch = 0; + for (int col = 0; col < kWide; col++) { + int x = col - fl; /* native column at this place */ + if (x >= -left && x < kPpuXPixels + right) { + if (out[col] != ref[x + kRefExtra]) + world_mismatch++; + } else if (out[col] != 0) { + pad_mismatch++; + } + } + snprintf(msg, sizeof msg, + "%s: frame column x + extraFrameLeft carries native column x", + label); + check(world_mismatch == 0, msg); + snprintf(msg, sizeof msg, "%s: everything outside the live span is void", + label); + check(pad_mismatch == 0, msg); + snprintf(msg, sizeof msg, "%s: padding is equal either side (+/-1)", + label); + check((fl - left) - (kSpan - fl - right) >= -1 && + (fl - left) - (kSpan - fl - right) <= 0, msg); + check_slack(out, kWide, label); +} + +int main(void) { + Ppu *ppu = ppu_init(); + if (!ppu) return 2; + + const size_t ref_stride = (size_t)(kRefWide + kSlack); + const size_t stride = (size_t)(kWide + kSlack); + uint32_t *refbuf = calloc(ref_stride * kRows, sizeof(uint32_t)); + uint32_t *refbuf_hud = calloc(ref_stride * kRows, sizeof(uint32_t)); + uint32_t *buf = calloc(stride * kRows, sizeof(uint32_t)); + if (!refbuf || !refbuf_hud || !buf) { + fprintf(stderr, "FAIL: oom\n"); + return 2; + } + /* Oracles: the same scene with 2*budget live on BOTH sides, so native + * column x sits at frame column x + kRefExtra and every column an anchored + * frame can reach is present. The HUD oracle is BG3 alone and unsplit -- + * the authentic source each chunk must reproduce. */ + const uint32_t *ref = render(ppu, refbuf, kRefWide, kRefExtra, -1, 0, 0, + kBg1, false, kWorldLine); + const uint32_t *ref_hud = render(ppu, refbuf_hud, kRefWide, kRefExtra, -1, + 0, 0, kBg3, false, kHudLine); + + /* 1/2. The two walls. At the left edge the game clamps its left margin to + * zero and the whole 2*budget allowance becomes world on the right; at the + * right edge the mirror image. */ + check_case(ppu, buf, ref, 0, kSpan, 0, "left wall (0, 2B)"); + check_case(ppu, buf, ref, kSpan, 0, kSpan, "right wall (2B, 0)"); + + /* Spelled out, because it is what the whole feature is for. */ + { + const uint32_t *out = render(ppu, buf, kWide, kBudget, 1, 0, kSpan, + kBg1, false, kWorldLine); + check(out[0] == ref[0 + kRefExtra], + "left wall: native x=0 is at frame column 0"); + int margin_ok = 1; + for (int k = 0; k < kSpan; k++) + if (out[kPpuXPixels + k] != ref[kPpuXPixels + k + kRefExtra]) + margin_ok = 0; + check(margin_ok, + "left wall: world columns 256..256+2B-1 fill the right margin"); + + out = render(ppu, buf, kWide, kBudget, 1, kSpan, 0, kBg1, false, + kWorldLine); + check(out[kWide - 1] == ref[kPpuXPixels - 1 + kRefExtra], + "right wall: native x=255 is at the last frame column"); + int left_ok = 1; + for (int k = 1; k <= kSpan; k++) + if (out[kSpan - k] != ref[-k + kRefExtra]) + left_ok = 0; + check(left_ok, + "right wall: world columns -1..-2B fill the left margin"); + } + + /* 3. Narrower than the frame: the live span centres with equal padding. + * (0,0) is the pillarbox a bounded screen asks for; (a,b) with a+b < 2B is + * a level only a little narrower than 16:9. The odd remainder goes right, + * because extraFrameLeft takes the floor of pad/2. */ + check_case(ppu, buf, ref, 0, 0, kBudget, "narrow level (0, 0)"); + check_case(ppu, buf, ref, 10, 20, 10 + (kSpan - 30) / 2, + "narrow level (10, 20)"); + check_case(ppu, buf, ref, 10, 21, 10 + (kSpan - 31) / 2, + "odd pad (10, 21)"); + { + render(ppu, buf, kWide, kBudget, 1, 10, 21, kBg1, false, kWorldLine); + int pad_l = (int)ppu->extraFrameLeft - 10; + int pad_r = kSpan - (int)ppu->extraFrameLeft - 21; + check(pad_l == 27 && pad_r == 28, + "odd pad: the spare column goes to the right side"); + } + + /* 4. Anchoring OFF keeps the fixed centre origin for every margin inside + * the budget -- the rule every shipping consumer renders with today. */ + { + static const int cases[][2] = { {0,0}, {20,30}, {43,43}, {5,43} }; + for (size_t i = 0; i < sizeof cases / sizeof *cases; i++) { + int l = cases[i][0], r = cases[i][1]; + const uint32_t *out = render(ppu, buf, kWide, kBudget, 0, l, r, + kBg1, false, kWorldLine); + char msg[160]; + snprintf(msg, sizeof msg, + "anchoring off (%d,%d): origin is the fixed budget", l, r); + check(ppu->extraFrameLeft == kBudget && + ppu->extraFrameRight == kBudget, msg); + int bad = 0; + for (int col = 0; col < kWide; col++) { + int x = col - kBudget; + if (x >= -l && x < kPpuXPixels + r) { + if (out[col] != ref[x + kRefExtra]) bad++; + } else if (out[col] != 0) { + bad++; + } + } + snprintf(msg, sizeof msg, + "anchoring off (%d,%d): native x sits at frame column x+B", + l, r); + check(bad == 0, msg); + check_slack(out, kWide, "anchoring off"); + } + /* The per-side clamp to the budget is unchanged too: asking for more + * than the budget with anchoring off still yields the budget. */ + render(ppu, buf, kWide, kBudget, 0, kSpan, kSpan, kBg1, false, + kWorldLine); + check(ppu->extraLeftCur == kBudget && ppu->extraRightCur == kBudget, + "anchoring off: per-side margins still clamp to the budget"); + } + + /* 5. The split BG3 HUD. Its outer chunks ride the PHYSICAL frame edges and + * its centre group rides the SLID origin -- a fixed-origin render puts the + * outer chunks in the same two places but leaves the centre group at + * left_end + budget instead of left_end + extraFrameLeft. */ + { + static const struct { int left, right, fl; const char *label; } hud[] = { + { 0, kSpan, 0, "hud at the left wall" }, + { kSpan, 0, kSpan, "hud at the right wall" }, + { 0, 0, kBudget, "hud on a narrow level" }, + }; + for (size_t i = 0; i < sizeof hud / sizeof *hud; i++) { + const uint32_t *out = render(ppu, buf, kWide, kBudget, 1, + hud[i].left, hud[i].right, kBg3, true, + kHudLine); + const int fl = hud[i].fl; + char msg[160]; + int bad = 0; + + check(ppu->extraFrameLeft == fl, + "hud case: the origin is where the margins put it"); + /* Left chunk: source [0,left_end) flush to physical column 0. */ + for (int k = 0; k < kHudLeftEnd; k++) + if (out[k] != ref_hud[k + kRefExtra]) bad++; + snprintf(msg, sizeof msg, + "%s: left chunk is flush with the physical left edge", + hud[i].label); + check(bad == 0, msg); + /* Right chunk: source [right_start,256) flush to the last column. */ + bad = 0; + for (int k = kHudRightStart; k < kPpuXPixels; k++) + if (out[k + kSpan] != ref_hud[k + kRefExtra]) bad++; + snprintf(msg, sizeof msg, + "%s: right chunk is flush with the physical right edge", + hud[i].label); + check(bad == 0, msg); + /* Centre group: stationary in screen space as the world slides. */ + bad = 0; + for (int k = kHudLeftEnd; k < kHudRightStart; k++) + if (out[k + kBudget] != ref_hud[k + kRefExtra]) bad++; + snprintf(msg, sizeof msg, + "%s: centre group stays at the physical centre", hud[i].label); + check(bad == 0, msg); + check_slack(out, kWide, hud[i].label); + } + /* The HUD band must not have disturbed the rows below it. */ + const uint32_t *out = render(ppu, buf, kWide, kBudget, 1, 0, kSpan, + kBg1, true, kWorldLine); + int bad = 0; + for (int col = 0; col < kWide; col++) + if (out[col] != ref[col + kRefExtra]) bad++; + check(bad == 0, + "hud split: rows below the band still carry the anchored world"); + } + + /* 6. Budget 0: authentic 256-wide output, anchoring on or off. */ + { + const size_t narrow = (size_t)(kPpuXPixels + kSlack); + uint32_t *a = calloc(narrow * kRows, sizeof(uint32_t)); + uint32_t *b = calloc(narrow * kRows, sizeof(uint32_t)); + if (a && b) { + const uint32_t *plain = render(ppu, a, kPpuXPixels, 0, -1, 0, 0, + kBg1 | kBg3, false, kWorldLine); + const uint32_t *anchored = render(ppu, b, kPpuXPixels, 0, 1, 0, 0, + kBg1 | kBg3, false, kWorldLine); + check(memcmp(plain, anchored, + sizeof(uint32_t) * kPpuXPixels) == 0, + "budget 0: anchoring leaves authentic 256-wide output alone"); + check(ppu->extraFrameLeft == 0 && ppu->extraFrameRight == 0, + "budget 0: the origin is 0 either side"); + } else { + fprintf(stderr, "FAIL: oom\n"); + failures++; + } + free(a); + free(b); + } + + /* The invariant, swept over every pair a game could publish -- including + * the out-of-range ones the setter has to absorb. */ + { + int bad = 0; + for (int l = 0; l <= kSpan + 8; l++) + for (int r = 0; r <= kSpan + 8; r++) { + PpuSetExtraSpace(ppu, kBudget); + PpuSetWidescreenViewAnchor(ppu, true); + PpuSetExtraSideSpace(ppu, l, r, 0); + if (ppu->extraFrameLeft + ppu->extraFrameRight != kSpan || + ppu->extraFrameLeft < ppu->extraLeftCur || + ppu->extraFrameRight < ppu->extraRightCur || + ppu->extraLeftCur + ppu->extraRightCur > kSpan) + bad++; + } + check(bad == 0, + "sweep: the origin spans 2*budget and always covers both margins"); + } + + /* Enabling the anchor re-derives the origin from the margins already + * published, so a game may publish the two in either order within a frame. + * (The margins themselves stay whatever the last publish clamped them to, + * which is why a game re-publishes both every frame.) */ + { + PpuSetExtraSpace(ppu, kBudget); + PpuSetExtraSideSpace(ppu, 0, kSpan, 0); + check(ppu->extraLeftCur == 0 && ppu->extraRightCur == kBudget && + ppu->extraFrameLeft == kBudget, + "anchor off: a level-edge margin clamps to the budget"); + PpuSetWidescreenViewAnchor(ppu, true); + check(ppu->extraFrameLeft == (kSpan - kBudget) / 2 && + ppu->extraFrameRight == kSpan - (kSpan - kBudget) / 2, + "enabling the anchor re-derives the origin in place"); + PpuSetWidescreenViewAnchor(ppu, false); + check(ppu->extraFrameLeft == kBudget && + ppu->extraFrameRight == kBudget, + "disabling the anchor restores the fixed centre origin"); + /* PpuSetExtraSpace* reset it, like every other widescreen policy. */ + PpuSetWidescreenViewAnchor(ppu, true); + PpuSetExtraSpace(ppu, kBudget); + check(ppu->wsViewAnchor == 0, + "PpuSetExtraSpace resets the anchor with the other policies"); + ppu_reset(ppu); + check(ppu->wsViewAnchor == 0 && ppu->extraFrameLeft == 0 && + ppu->extraFrameRight == 0, + "ppu_reset zeroes the anchor state with the other ws fields"); + } + + /* 7. The new storage is host state: it must sit between the register + * snapshot and the memory snapshot, in neither. */ + check(offsetof(Ppu, wsViewAnchor) > offsetof(Ppu, cgwsel) && + offsetof(Ppu, extraFrameLeft) > offsetof(Ppu, cgwsel) && + offsetof(Ppu, extraFrameRight) + sizeof(uint16_t) <= + offsetof(Ppu, cgram), + "view-anchor storage lies outside both PPU savestate spans"); + + free(refbuf); + free(refbuf_hud); + free(buf); + ppu_free(ppu); + printf("ppu_view_anchor_test: %s\n", failures ? "FAIL" : "PASS"); + return failures ? 1 : 0; +} diff --git a/tests/ppu/run.ps1 b/tests/ppu/run.ps1 index b01565a9..f80b7183 100644 --- a/tests/ppu/run.ps1 +++ b/tests/ppu/run.ps1 @@ -1,7 +1,7 @@ $ErrorActionPreference = "Stop" $root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $gcc = (Get-Command gcc).Source -$tests = @("ppu_sprite_limit_test", "ppu_world_mirror_test", "ppu_elastic_band_test", "ppu_mode7_hd_test", "ppu_raster_journal_test", "ppu_hires_mode_test") +$tests = @("ppu_sprite_limit_test", "ppu_world_mirror_test", "ppu_elastic_band_test", "ppu_view_anchor_test", "ppu_mode7_hd_test", "ppu_raster_journal_test", "ppu_hires_mode_test") $failed = @() foreach ($test in $tests) { diff --git a/tests/run_c_tests.sh b/tests/run_c_tests.sh index 0b3a3b10..cdcfb441 100644 --- a/tests/run_c_tests.sh +++ b/tests/run_c_tests.sh @@ -88,6 +88,14 @@ echo "=== PPU widescreen elastic anchor band ===" "$CC" -std=c11 -Wall -Wextra -O1 -DSNESRECOMP_REVERSE_DEBUG=0 -I "$ROOT/runner/src" -I "$ROOT/runner/src/snes" "$ROOT/tests/ppu/ppu_elastic_band_test.c" "$ROOT/runner/src/snes/ppu.c" "$ROOT/runner/src/snes/ppu_legacy.c" -lm -o "$OUT/ppu_elastic_band_test" "$OUT/ppu_elastic_band_test" +echo "=== PPU bounded view anchoring ===" +"$CC" -std=c11 -Wall -Wextra -O1 -DSNESRECOMP_REVERSE_DEBUG=0 \ + -I "$ROOT/runner/src" -I "$ROOT/runner/src/snes" \ + "$ROOT/tests/ppu/ppu_view_anchor_test.c" \ + "$ROOT/runner/src/snes/ppu.c" "$ROOT/runner/src/snes/ppu_legacy.c" \ + -lm -o "$OUT/ppu_view_anchor_test" +"$OUT/ppu_view_anchor_test" + echo "=== HD Mode 7 sampling and composition ===" "$CC" -std=c11 -Wall -Wextra -O1 \ -DSNESRECOMP_REVERSE_DEBUG=0 \ From b94e0a43c65da718449bd3f7abe927cb38ab694c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 17:41:55 -0700 Subject: [PATCH 14/17] Reserve full line-buffer reach for ultra-wide view anchoring --- docs/WIDESCREEN_PATTERNS.md | 11 +- runner/src/debug_server.c | 8 +- runner/src/desktop/host_main.c | 6 +- runner/src/snes/ppu.c | 120 ++++++++++---------- runner/src/snes/ppu.h | 35 +++--- runner/src/snes/ppu_legacy.c | 6 +- tests/ppu/ppu_8bpp_contract_test.c | 4 +- tests/ppu/ppu_composition_regression_test.c | 12 +- tests/ppu/ppu_sprite_limit_test.c | 8 +- tests/ppu/ppu_view_anchor_test.c | 77 ++++++++++++- 10 files changed, 182 insertions(+), 105 deletions(-) diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index 489a95bc..dfa5c7d7 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -226,8 +226,15 @@ physical-centre HUD correction. The parked X1 worktrees remain untouched. Consumers must validate their own camera bounds, streaming and spawn envelope; this engine change does not enable anchoring for any title automatically. `ppu_view_anchor_test` checks actual image placement, both edges, narrow rooms, -HUD, native/default-off output, reset and storage boundaries. One-sided spans -remain limited by the existing priority-buffer and ordinary 9-bit OBJ ranges. +HUD, native/default-off output, reset and storage boundaries. Priority buffers +reserve the entire maximum expansion on either side: `kPpuMaxFrameExtra` caps +the symmetric output budget at 272 and `kPpuLineBorder` is the signed-column +storage origin. The old `kPpuExtraLeftRight` name remains an origin alias so +existing line enhancers keep indexing the same native columns. Presented +frames remain capped at 800 pixels. The 684/800 tests use independent 256px +renders as their oracle, including both extremes and physical HUD placement. +Ordinary 9-bit OBJ coordinates still require title-owned positioning/hints; +larger background buffers alone do not establish ultra-wide sprite coverage. ### P3. Periodic layers fold; world-anchored layers use history diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 13ac06e3..33efbe57 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -5055,13 +5055,13 @@ static void cmd_ppu_lines(const char *args) { /* The last frame a title-owned presenter composed. See the declaration in * debug_server.h: without it every capture of an enhanced render path shows * the authentic 256-column raster instead of what the player is looking at. */ -static uint8_t s_composed_pixels[kPpuBufWidth * 4 * 240]; +static uint8_t s_composed_pixels[kPpuMaxFrameWidth * 4 * 240]; static int s_composed_width, s_composed_height; static int s_composed_valid; void debug_server_note_composed_frame(const void *argb, unsigned pitch, int width, int height) { - if (!argb || width <= 0 || width > kPpuBufWidth || height <= 0 || + if (!argb || width <= 0 || width > kPpuMaxFrameWidth || height <= 0 || height > 240 || pitch < (unsigned)width * 4) { s_composed_valid = 0; return; @@ -5080,7 +5080,7 @@ static void cmd_screenshot(const char *args) { // Copy the most recently presented buffer. Re-rendering here loses // presentation-only layers (including widescreen coprocessor pixels) and // can observe forced blank after the frame has already been drawn. - static uint8_t scr_pixels[kPpuBufWidth * 4 * 240]; + static uint8_t scr_pixels[kPpuMaxFrameWidth * 4 * 240]; int w, h; const char *source; if (s_composed_valid) { @@ -5136,7 +5136,7 @@ static void cmd_screenshot(const char *args) { fwrite(hdr, 1, 54, f); // Write pixels (BGRA -> BGR, top to bottom) - uint8_t row_buf[kPpuBufWidth * 3 + 4]; + uint8_t row_buf[kPpuMaxFrameWidth * 3 + 4]; memset(row_buf, 0, sizeof(row_buf)); for (int y = 0; y < h; y++) { const uint8_t *src = scr_pixels + y * w * 4; diff --git a/runner/src/desktop/host_main.c b/runner/src/desktop/host_main.c index 91ea98ea..cccd9434 100644 --- a/runner/src/desktop/host_main.c +++ b/runner/src/desktop/host_main.c @@ -189,7 +189,7 @@ struct SpcPlayer *g_spc_player; * renderer needs a stable target for the whole field (and the raster IRQ * runs guest code in the middle of it). Sized for the runner's maximum * side-space budget. */ -static uint8_t g_my_pixels[kPpuBufWidth * 4 * 240]; +static uint8_t g_my_pixels[kPpuMaxFrameWidth * 4 * 240]; extern uint8_t g_ram[0x20000]; @@ -699,7 +699,7 @@ static void PreparePpuFrame(void) { int fh = g_game->frame_height > 0 ? g_game->frame_height : 224; if (g_game->prepare_frame) g_game->prepare_frame(drawable_width, drawable_height, &fw, &fh); - int max_width = g_game->native_widescreen ? kPpuBufWidth : kSnesDesktopMaxFrameWidth; + int max_width = g_game->native_widescreen ? kPpuMaxFrameWidth : kSnesDesktopMaxFrameWidth; if (fw <= 0 || fw > max_width) fw = 256; if (fh <= 0 || fh > 240) fh = 224; g_snes_width = fw; @@ -1449,7 +1449,7 @@ static void DrawPpuFrameWithPerf(void) { frame_bytes <= sizeof(g_frame_stage); #if defined(SNESRECOMP_HOST_HAS_BLEND) const bool blending = g_config.frame_blend && g_blend && - frame_bytes <= (size_t)kPpuBufWidth * 4u * 240u; + frame_bytes <= (size_t)kPpuMaxFrameWidth * 4u * 240u; staged |= blending; #endif if (staged) { diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 6ed8a108..8690d9ab 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -249,9 +249,9 @@ bool PpuSetOverlayCapture(Ppu *ppu, PpuOverlaySource source, return false; int64_t requested_x1 = (int64_t)x + width; int64_t requested_y1 = (int64_t)y + height; - int x0 = IntMax(x, -kPpuExtraLeftRight); - int x1 = requested_x1 < kPpuXPixels + kPpuExtraLeftRight - ? (int)requested_x1 : kPpuXPixels + kPpuExtraLeftRight; + int x0 = IntMax(x, -kPpuLineBorder); + int x1 = requested_x1 < kPpuXPixels + kPpuLineBorder + ? (int)requested_x1 : kPpuXPixels + kPpuLineBorder; int y0 = IntMax(y, 0); int y1 = requested_y1 < 240 ? (int)requested_y1 : 240; if (x1 <= x0 || y1 <= y0) @@ -317,15 +317,15 @@ int PpuWsExtraOverride(void) { if (s_ov == -2) { const char *e = getenv("SNESRECOMP_WS_EXTRA"); s_ov = (e && e[0]) ? atoi(e) : -1; - if (s_ov > kPpuExtraLeftRight) - s_ov = kPpuExtraLeftRight; + if (s_ov > kPpuMaxFrameExtra) + s_ov = kPpuMaxFrameExtra; } return s_ov; } void PpuSetExtraSpace(Ppu *ppu, uint16_t extra) { - if (extra > kPpuExtraLeftRight) - extra = kPpuExtraLeftRight; + if (extra > kPpuMaxFrameExtra) + extra = kPpuMaxFrameExtra; // Symmetric border: equal columns added on each side. extraLeftRight is the // centering budget; extraLeftCur/extraRightCur are the per-side columns the // window/sprite/composite paths actually render. @@ -338,8 +338,8 @@ void PpuSetExtraSpace(Ppu *ppu, uint16_t extra) { } void PpuSetExtraSpaceCentered(Ppu *ppu, uint16_t budget) { - if (budget > kPpuExtraLeftRight) - budget = kPpuExtraLeftRight; + if (budget > kPpuMaxFrameExtra) + budget = kPpuMaxFrameExtra; // Render only the authentic 256 columns but keep the centering budget so the // composite places them in the middle of the wider framebuffer (the caller // clears the side margins to black -> letterbox/pillarbox). Used for bounded @@ -365,7 +365,7 @@ void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom) { // origin slides with them so a margin the game clamped to 0 becomes // picture on the other side instead of a black bar on this one. const int span = budget * 2; - const int cap = IntMin(span, kPpuExtraLeftRight); + const int cap = IntMin(span, kPpuLineBorder); left = IntMin(IntMax(left, 0), cap); right = IntMin(IntMax(right, 0), cap); if (right > span - left) @@ -373,12 +373,10 @@ void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom) { // Whatever the two margins leave unclaimed is padding, split evenly; the // odd column goes right, because the origin takes the floor. int frame_left = left + (span - left - right) / 2; - // The priority buffers only hold kPpuExtraLeftRight columns either side of - // native x, so a budget past half of that cannot slide the whole way. - // Saturate and leave the remainder pillarboxed rather than addressing - // columns that do not exist. - frame_left = IntMin(IntMax(frame_left, span - kPpuExtraLeftRight), - kPpuExtraLeftRight); + // Buffers reserve the whole maximum allowance on either side, so this + // clamp is defensive: every supported output budget can slide fully. + frame_left = IntMin(IntMax(frame_left, span - kPpuLineBorder), + kPpuLineBorder); ppu->extraLeftCur = (uint16_t)left; ppu->extraRightCur = (uint16_t)right; ppu->extraFrameLeft = (uint16_t)frame_left; @@ -766,12 +764,12 @@ void ppu_handleVblank(Ppu* ppu) { static inline void PpuActiveSpan(const Ppu *ppu, size_t *begin, size_t *end) { size_t left = ppu->extraFrameLeft, right = ppu->extraFrameRight; - if (left > kPpuExtraLeftRight) - left = kPpuExtraLeftRight; - if (right > kPpuExtraLeftRight) - right = kPpuExtraLeftRight; - size_t b = ((size_t)kPpuExtraLeftRight - left) & ~(size_t)3; - size_t e = ((size_t)kPpuExtraLeftRight + kPpuXPixels + right + 3) & + if (left > kPpuLineBorder) + left = kPpuLineBorder; + if (right > kPpuLineBorder) + right = kPpuLineBorder; + size_t b = ((size_t)kPpuLineBorder - left) & ~(size_t)3; + size_t e = ((size_t)kPpuLineBorder + kPpuXPixels + right + 3) & ~(size_t)3; if (e > kPpuBufWidth) e = kPpuBufWidth; @@ -1267,8 +1265,8 @@ static void PpuDrawBackground_4bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, PpuZbufType zhi, PpuZbufType zlo) { #define VIEWPORT_ALLOWED(i) \ PpuViewportAllows(ppu, layer, \ - (int)(dstz + (i) - dstbuf->data - kPpuExtraLeftRight), \ - (int)(dstz + (i) - dstbuf->data - kPpuExtraLeftRight) + \ + (int)(dstz + (i) - dstbuf->data - kPpuLineBorder), \ + (int)(dstz + (i) - dstbuf->data - kPpuLineBorder) + \ ws_bias[windex]) #define DO_PIXEL(i) do { \ pixel = (bits >> i) & 1 | (bits >> (7 + i)) & 2 | (bits >> (14 + i)) & 4 | (bits >> (21 + i)) & 8; \ @@ -1305,7 +1303,7 @@ static void PpuDrawBackground_4bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint x = win.edges[windex] + ppu->hScroll[layer] + ws_bias[windex]; uint w = win.edges[windex + 1] - win.edges[windex]; int ws_sx = win.edges[windex]; - PpuZbufType *dstz = dstbuf->data + win.edges[windex] + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + win.edges[windex] + kPpuLineBorder; const uint16 *tp = tps[x >> 8 & 1] + ((x >> 3) & 0x1f); const uint16 *tp_last = tps[x >> 8 & 1] + 31; const uint16 *tp_next = tps[(x >> 8 & 1) ^ 1]; @@ -1405,7 +1403,7 @@ static void PpuDrawBackground_8bpp(Ppu *ppu, uint y, bool sub, uint layer, continue; const int left = win.edges[windex]; const int right = win.edges[windex + 1]; - PpuZbufType *dstz = ppu->bgBuffers[sub].data + left + kPpuExtraLeftRight; + PpuZbufType *dstz = ppu->bgBuffers[sub].data + left + kPpuLineBorder; for (int screen_x = left; screen_x < right;) { const int sx = screen_x + ppu->hScroll[layer]; @@ -1496,7 +1494,7 @@ static void PpuDrawBackgroundBig(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, continue; const int left = win.edges[windex]; const int right = win.edges[windex + 1]; - PpuZbufType *dstz = dstbuf->data + left + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + left + kPpuLineBorder; const bool ws_shadow = WsShadowLayerActive((int)layer); for (int screen_x = left; screen_x < right; screen_x++, dstz++) { const int mx = mosaic ? PpuMosaicAt(ppu, screen_x) : screen_x; @@ -1675,7 +1673,7 @@ static void PpuDrawBackground_4bpp_opt(Ppu *ppu, uint y, bool sub, uint layer, const int right = win.edges[windex + 1]; const int sample_bias = ws_bias[windex]; PpuZbufType *dstz = - ppu->bgBuffers[sub].data + left + kPpuExtraLeftRight; + ppu->bgBuffers[sub].data + left + kPpuLineBorder; /* OPT values apply to a rendered segment, not independently to every * screen pixel. The selected horizontal offset determines where the * next eight-pixel source-tile boundary lies; only there does the PPU @@ -1837,7 +1835,7 @@ static void PpuDrawBackground_2bpp(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, b continue; // layer is disabled for this window part uint x = win.edges[windex] + ppu->hScroll[layer] + ws_bias[windex]; uint w = win.edges[windex + 1] - win.edges[windex]; - PpuZbufType *dstz = dstbuf->data + win.edges[windex] + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + win.edges[windex] + kPpuLineBorder; const uint16 *tp = tps[x >> 8 & 1] + ((x >> 3) & 0x1f); const uint16 *tp_last = tps[x >> 8 & 1] + 31; const uint16 *tp_next = tps[(x >> 8 & 1) ^ 1]; @@ -1914,8 +1912,8 @@ static void PpuDrawBackground_4bpp_mosaic(Ppu *ppu, PpuZbufType zhi, PpuZbufType zlo) { #define VIEWPORT_ALLOWED(i) \ PpuViewportAllows(ppu, layer, \ - (int)(dstz + (i) - dstbuf->data - kPpuExtraLeftRight), \ - (int)(dstz + (i) - dstbuf->data - kPpuExtraLeftRight)) + (int)(dstz + (i) - dstbuf->data - kPpuLineBorder), \ + (int)(dstz + (i) - dstbuf->data - kPpuLineBorder)) #define GET_PIXEL() pixel = (bits) & 1 | (bits >> 7) & 2 | (bits >> 14) & 4 | (bits >> 21) & 8 #define GET_PIXEL_HFLIP() pixel = (bits >> 7) & 1 | (bits >> 14) & 2 | (bits >> 21) & 4 | (bits >> 28) & 8 #define READ_BITS(ta, tile) (addr = &PpuRenderVram(ppu)[((ta) + (tile) * 16) & 0x7fff], addr[0] | addr[8] << 16) @@ -1942,8 +1940,8 @@ static void PpuDrawBackground_4bpp_mosaic(Ppu *ppu, continue; // layer is disabled for this window part int sx = win.edges[windex]; int ws_sx = sx; - PpuZbufType *dstz = dstbuf->data + sx + kPpuExtraLeftRight; - PpuZbufType *dstz_end = dstbuf->data + win.edges[windex + 1] + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + sx + kPpuLineBorder; + PpuZbufType *dstz_end = dstbuf->data + win.edges[windex + 1] + kPpuLineBorder; uint x = sx + ppu->hScroll[layer]; const uint16 *tp = tps[x >> 8 & 1] + ((x >> 3) & 0x1f); const uint16 *tp_last = tps[x >> 8 & 1] + 31, *tp_next = tps[(x >> 8 & 1) ^ 1]; @@ -1989,20 +1987,20 @@ static void PpuMergePaddedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, int left_extra = full_budget ? ppu->extraFrameLeft : ppu->extraLeftCur; int right_extra = full_budget ? ppu->extraFrameRight : ppu->extraRightCur; for (int x = 0; x < kPpuXPixels; x++) { - int i = x + kPpuExtraLeftRight; + int i = x + kPpuLineBorder; if (src[i] > dst[i]) dst[i] = src[i]; } for (int x = -left_extra; x < 0; x++) { - int di = x + kPpuExtraLeftRight; + int di = x + kPpuLineBorder; int sx = repeat ? kPpuXPixels + x : -x; - int si = sx + kPpuExtraLeftRight; + int si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } for (int x = kPpuXPixels; x < kPpuXPixels + right_extra; x++) { - int di = x + kPpuExtraLeftRight; + int di = x + kPpuLineBorder; int sx = repeat ? x - kPpuXPixels : kPpuXPixels * 2 - 2 - x; - int si = sx + kPpuExtraLeftRight; + int si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } } @@ -2046,7 +2044,7 @@ static void PpuMergeWorldBoundedBackground(Ppu *ppu, } if (sx < -left_extra || sx >= kPpuXPixels + right_extra) continue; // that source column was never rendered this line - int di = x + kPpuExtraLeftRight, si = sx + kPpuExtraLeftRight; + int di = x + kPpuLineBorder, si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } @@ -2084,7 +2082,7 @@ static void PpuMergeElasticBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, int sx = src0 + ((2 * (x - dst0) + 1) * sw) / (2 * dw); if (sx < 0 || sx >= kPpuXPixels) continue; - int di = x + kPpuExtraLeftRight, si = sx + kPpuExtraLeftRight; + int di = x + kPpuLineBorder, si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } @@ -2102,11 +2100,11 @@ static void PpuMergeStretchedBackground(Ppu *ppu, PpuPixelPrioBufs *dstbuf, return; for (int x = -left_extra; x < kPpuXPixels + right_extra; x++) { - int di = x + kPpuExtraLeftRight; + int di = x + kPpuLineBorder; int sx = ((x + left_extra) * kPpuXPixels) / out_width; if (sx < 0) sx = 0; if (sx >= kPpuXPixels) sx = kPpuXPixels - 1; - int si = sx + kPpuExtraLeftRight; + int si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } } @@ -2254,8 +2252,8 @@ static void PpuDrawBackground_2bpp_mosaic(Ppu *ppu, PpuPixelPrioBufs *dstbuf, in if (win.bits & (1 << windex)) continue; // layer is disabled for this window part int sx = win.edges[windex]; - PpuZbufType *dstz = dstbuf->data + sx + kPpuExtraLeftRight; - PpuZbufType *dstz_end = dstbuf->data + win.edges[windex + 1] + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + sx + kPpuLineBorder; + PpuZbufType *dstz_end = dstbuf->data + win.edges[windex + 1] + kPpuLineBorder; uint x = sx + ppu->hScroll[layer]; const uint16 *tp = tps[x >> 8 & 1] + ((x >> 3) & 0x1f); const uint16 *tp_last = tps[x >> 8 & 1] + 31, *tp_next = tps[(x >> 8 & 1) ^ 1]; @@ -2318,8 +2316,8 @@ static void PpuDrawBackground_mode7(Ppu *ppu, PpuPixelPrioBufs *dstbuf, uint y, if (win.bits & (1 << windex)) continue; // layer is disabled for this window part int x = win.edges[windex], x2 = win.edges[windex + 1], tile; - PpuZbufType *dstz = dstbuf->data + x + kPpuExtraLeftRight; - PpuZbufType *dstz_end = dstbuf->data + x2 + kPpuExtraLeftRight; + PpuZbufType *dstz = dstbuf->data + x + kPpuLineBorder; + PpuZbufType *dstz_end = dstbuf->data + x2 + kPpuLineBorder; uint32 rx = PPU_m7xFlip(ppu) ? 255 - x : x; uint32 xpos = m7startX + ppu->m7matrix[0] * rx; uint32 ypos = m7startY + ppu->m7matrix[2] * rx; @@ -2382,8 +2380,8 @@ static void PpuDrawSprites(Ppu *ppu, uint y, uint sub, bool clear_backdrop) { continue; // layer is disabled for this window part int left = win.edges[windex]; int width = win.edges[windex + 1] - left; - PpuZbufType *src = ppu->objBuffer.data + left + kPpuExtraLeftRight; - PpuZbufType *dst = ppu->bgBuffers[sub].data + left + kPpuExtraLeftRight; + PpuZbufType *src = ppu->objBuffer.data + left + kPpuLineBorder; + PpuZbufType *dst = ppu->bgBuffers[sub].data + left + kPpuLineBorder; if (clear_backdrop) { memcpy(dst, src, width * sizeof(uint16)); } else { @@ -2443,8 +2441,8 @@ static void PpuWriteOverlayRenderLine(Ppu *ppu, PpuOverlaySource source, const PpuOverlayCapture *capture = &ppu->overlayCaptures[source]; int x0 = IntMax(capture->x0, screen_min); int x1 = IntMin(capture->x1, screen_max); - if (x1 <= x0 || x0 + kPpuExtraLeftRight < 0 || - x1 + kPpuExtraLeftRight > kPpuBufWidth) + if (x1 <= x0 || x0 + kPpuLineBorder < 0 || + x1 + kPpuLineBorder > kPpuBufWidth) return; uint32 *dst = (uint32 *)(ppu->overlayRenderBuffer[source] + @@ -2452,7 +2450,7 @@ static void PpuWriteOverlayRenderLine(Ppu *ppu, PpuOverlaySource source, const PpuZbufType *src = ppu->overlayBuffers[source].data; for (int x = x0; x < x1; x++) dst[x + texture_extra] = - PpuOverlayColor(ppu, src[x + kPpuExtraLeftRight]); + PpuOverlayColor(ppu, src[x + kPpuLineBorder]); } // Choose the destination priority buffer for a background layer. When the @@ -2493,7 +2491,7 @@ static void PpuFinishBackgroundOverlay(Ppu *ppu, uint y, bool sub, size_t begin, end; PpuActiveSpan(ppu, &begin, &end); for (size_t i = begin; i < end; i++) { - int x = (int)i - kPpuExtraLeftRight; + int x = (int)i - kPpuLineBorder; if (remove && x >= capture->x0 && x < capture->x1) continue; if (src[i] > dst[i]) @@ -2691,12 +2689,12 @@ static NOINLINE void PpuDrawWholeLine(Ppu *ppu, uint y) { // leftmost column the composition windows will actually visit. dst += compose_full_budget ? 0 : (ppu->extraFrameLeft - ppu->extraLeftCur); - uint32 hud_world_left = kPpuExtraLeftRight - ppu->extraLeftCur; - uint32 hud_world_right = kPpuExtraLeftRight + 256 + ppu->extraRightCur; + uint32 hud_world_left = kPpuLineBorder - ppu->extraLeftCur; + uint32 hud_world_right = kPpuLineBorder + 256 + ppu->extraRightCur; uint32 windex = 0; do { - uint32 left = cwin.edges[windex] + kPpuExtraLeftRight, right = cwin.edges[windex + 1] + kPpuExtraLeftRight; + uint32 left = cwin.edges[windex] + kPpuLineBorder, right = cwin.edges[windex + 1] + kPpuLineBorder; // If clip is set, then zero out the rgb values from the main screen. uint32 clip_color_mask = (cw_clip_math & 1) ? 0x1f : 0; uint32 math_enabled_cur = PPU_mathEnabled(ppu) & ((cw_clip_math & 0x100) ? -1 : 0); @@ -2816,7 +2814,7 @@ static void PpuHdMarkWindow(uint8_t visibility[kPpuBufWidth], bool inside = (window->bits & (1u << span)) != 0; if (inside != mark_inside) continue; for (int x = window->edges[span]; x < window->edges[span + 1]; ++x) - visibility[x + kPpuExtraLeftRight] |= bit; + visibility[x + kPpuLineBorder] |= bit; } } @@ -2890,7 +2888,7 @@ static void PpuDrawMode7HdLine(Ppu *ppu, unsigned line) { int last_x = INT_MIN, last_y = INT_MIN; uint8_t index = 0; for (int x = -(int)ppu->extraLeftCur; x < 256 + ppu->extraRightCur; ++x) { - unsigned i = (unsigned)(x + kPpuExtraLeftRight); + unsigned i = (unsigned)(x + kPpuLineBorder); uint8_t visible = visibility[i]; uint16_t object = ppu->objBuffer.data[i]; for (unsigned sx = 0; sx < scale; ++sx) { @@ -3182,9 +3180,9 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { (((oam1 & 0xf) + (usedCol >> 3)) & 0xf); const uint16 *addr = &PpuRenderVram(ppu)[(objAdr + usedTile * 16 + (row & 0x7)) & 0x7fff]; uint32 plane = addr[0] | addr[8] << 16; - int px_left = IntMax(-(col + x + kPpuExtraLeftRight), 0); - int px_right = IntMin(256 + kPpuExtraLeftRight - (col + x), 8); - PpuZbufType *dst = ppu->objBuffer.data + col + x + px_left + kPpuExtraLeftRight; + int px_left = IntMax(-(col + x + kPpuLineBorder), 0); + int px_right = IntMin(256 + kPpuLineBorder - (col + x), 8); + PpuZbufType *dst = ppu->objBuffer.data + col + x + px_left + kPpuLineBorder; int slot = index >> 1; /* Clip an unhinted sprite at the SCREEN EDGE, per pixel. * @@ -3260,7 +3258,7 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { break; } bool extra=ppu->extraObjectCount && PpuComposeExtraObjects( - ppu->extraObjects,ppu->extraObjectCount,line,-kPpuExtraLeftRight, + ppu->extraObjects,ppu->extraObjectCount,line,-kPpuLineBorder, kPpuBufWidth,first_slot,ppu->objBuffer.data,ppu->objectColors,ppu->objectOrder); return tilesFound != 0 || extra; } diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index a6947836..2ab1cc90 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -34,14 +34,19 @@ typedef struct BgLayer { enum { kPpuXPixels = 256, - // Maximum widescreen expansion *per side*, baked into the priority-buffer - // capacity. This is a compile-time ceiling only; the actual extra columns - // rendered each frame are the runtime ppu->extraLeftCur/extraRightCur, which - // default to 0 (authentic 256-wide output). 272 per side allows up to an - // 800-pixel internal width for opt-in 32:9 presentation. - kPpuExtraLeftRight = 272, - // Full internal width of the priority buffers (logical 256 + both borders). - kPpuBufWidth = kPpuXPixels + kPpuExtraLeftRight * 2, + // Output budget: 272 columns per side permits an 800-pixel frame. Anchoring + // may put both sides' allowance on one side of the native viewport, so the + // priority buffers need twice that reach without raising the output limit. + kPpuMaxFrameExtra = 272, + kPpuMaxFrameWidth = kPpuXPixels + kPpuMaxFrameExtra * 2, + // Signed native-column reach and origin in the priority buffers. This is + // storage capacity, not the symmetric expansion accepted by the setters. + kPpuLineBorder = kPpuMaxFrameExtra * 2, + // Existing line enhancers use this name to index native pixel x in data[]. + // Retain it as an origin alias; use MaxFrameExtra for an output budget. + kPpuExtraLeftRight = kPpuLineBorder, + // Full priority-buffer width; a presented frame is at most MaxFrameWidth. + kPpuBufWidth = kPpuXPixels + kPpuLineBorder * 2, // Split-screen games can assign distinct anchor layouts to each viewport. kPpuWsAnchorBands = 2, // Number of stopped frames tolerated after a moving unhinted OBJ reaches the @@ -96,7 +101,7 @@ typedef struct PpuExtraObject { typedef PPU_ALIGN8_PRE struct PpuPixelPrioBufs { // This holds the prio in the upper 8 bits and the color in the lower 8 bits. // Sized for the widescreen border; logical screen x maps to - // data[x + kPpuExtraLeftRight]. + // data[x + kPpuLineBorder]. // // 8-aligned because ClearBackdrop fills this through a uint64 pointer. // The array itself only needs 2, so any member landing on a 4-mod-8 @@ -721,7 +726,7 @@ void ppu_rb_residue_set(Ppu *ppu, const PpuRollbackResidue *in); // composition. The callback is host-only and runs independently for main and // subscreen buffers. // The callback writes PRIORITY-BUFFER columns (native x at -// data[x + kPpuExtraLeftRight]), not framebuffer columns, so the widened span +// data[x + kPpuLineBorder]), not framebuffer columns, so the widened span // it should fill is the LIVE margin -- extraLeftCur/extraRightCur -- and never // the budget. An enhancer written that way needs no change under view // anchoring; one that reads extraLeftRight as "how far the margin reaches" @@ -760,7 +765,7 @@ bool PpuSetOverlayCapture(Ppu *ppu, PpuOverlaySource source, bool PpuSetOverlayOamRange(Ppu *ppu, uint8_t first, uint8_t count); // Set the symmetric widescreen border, in pixels per side (clamped to -// kPpuExtraLeftRight). 0 restores authentic 256-wide rendering. The internal +// kPpuMaxFrameExtra). 0 restores authentic 256-wide rendering. The internal // render width becomes 256 + 2*extra. Drives the dormant extraLeftCur/ // extraRightCur/extraLeftRight machinery used by the line renderer. void PpuSetExtraSpace(Ppu *ppu, uint16_t extra); @@ -825,9 +830,9 @@ void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom); // and every shipping budget (43 px MMX, 71 px SMW 16:9) is far inside it, // so this is documented rather than clamped -- clamping would throw away // real background columns to spare a sprite artefact. -// - The priority buffers carry kPpuExtraLeftRight columns per side, so a -// budget past half of that cannot slide the whole way; the origin saturates -// and the remainder stays pillarboxed. +// - The output budget is capped at kPpuMaxFrameExtra, while the priority +// buffers carry twice that many columns per side. Every supported frame +// can therefore put its full expansion on either side without padding. void PpuSetWidescreenViewAnchor(Ppu *ppu, bool enabled); // Widescreen HUD split (opt-in, configured by the game frontend): for @@ -861,7 +866,7 @@ void PpuSetWsHudOamBand(Ppu *ppu, uint8_t height, uint8_t left_end, * pins the per-side margin regardless of window size (0 forces 4:3; unset * returns -1 = follow the window). Games consult this when computing their * widescreen frame width so measured margins never silently depend on - * window-manager geometry. Clamped to kPpuExtraLeftRight. */ + * window-manager geometry. Clamped to kPpuMaxFrameExtra. */ int PpuWsExtraOverride(void); void PpuSetWsHudOamShift(Ppu *ppu, uint8_t nslots); diff --git a/runner/src/snes/ppu_legacy.c b/runner/src/snes/ppu_legacy.c index 6a4511fc..da490177 100644 --- a/runner/src/snes/ppu_legacy.c +++ b/runner/src/snes/ppu_legacy.c @@ -188,8 +188,8 @@ static int ppu_resolve_pixel(Ppu *ppu, int x, int y, bool sub, } else { // get a pixel from the sprite buffer pixel = 0; - if ((ppu->objBuffer.data[x + kPpuExtraLeftRight] >> 12) == SPRITE_PRIO_TO_PRIO_HI(curPriority)) - pixel = ppu->objBuffer.data[x + kPpuExtraLeftRight] & 0xff; + if ((ppu->objBuffer.data[x + kPpuLineBorder] >> 12) == SPRITE_PRIO_TO_PRIO_HI(curPriority)) + pixel = ppu->objBuffer.data[x + kPpuLineBorder] & 0xff; } } if (pixel > 0) { @@ -204,7 +204,7 @@ static int ppu_resolve_pixel(Ppu *ppu, int x, int y, bool sub, *b = ((pixel & 0xc0) >> 3) | ((pixel & 0x400) >> 8); } else { uint16_t color = layer==4 - ? PpuPixelColor(ppu,ppu->objBuffer.data[x+kPpuExtraLeftRight],x+kPpuExtraLeftRight) + ? PpuPixelColor(ppu,ppu->objBuffer.data[x+kPpuLineBorder],x+kPpuLineBorder) : ppu->cgram[pixel & 0xff]; *r = color & 0x1f; *g = (color >> 5) & 0x1f; diff --git a/tests/ppu/ppu_8bpp_contract_test.c b/tests/ppu/ppu_8bpp_contract_test.c index 4d2922ee..f4497569 100644 --- a/tests/ppu/ppu_8bpp_contract_test.c +++ b/tests/ppu/ppu_8bpp_contract_test.c @@ -19,9 +19,9 @@ static int known_planes(Ppu *p) { PpuDrawBackground_8bpp(p, 0, false, 0, 0xc000, 0x8000); for (int x = 0; x < 8; x++) { unsigned want = 0x8000 + (1u << (flip ? 7 - x : x)); - if (p->bgBuffers[0].data[kPpuExtraLeftRight + x] != want) + if (p->bgBuffers[0].data[kPpuLineBorder + x] != want) return fail_at("known_planes", flip, x, - p->bgBuffers[0].data[kPpuExtraLeftRight + x], want); + p->bgBuffers[0].data[kPpuLineBorder + x], want); } } return 0; diff --git a/tests/ppu/ppu_composition_regression_test.c b/tests/ppu/ppu_composition_regression_test.c index 00ada887..4aaf4eba 100644 --- a/tests/ppu/ppu_composition_regression_test.c +++ b/tests/ppu/ppu_composition_regression_test.c @@ -89,14 +89,14 @@ static int test_mode2_world_provider(Ppu *ppu) { PpuDrawBackground_4bpp_opt(ppu,1,false,0,0xc000,0x8000); test_shadow_active=false; if (!test_shadow_calls || test_shadow_hscroll!=0x2023 || test_shadow_y!=0x29 || - ppu->bgBuffers[0].data[kPpuExtraLeftRight+100]!=0x8001 || - ppu->bgBuffers[0].data[kPpuExtraLeftRight-10]!=0x8002 || - ppu->bgBuffers[0].data[kPpuExtraLeftRight+260]!=0x8002) { + ppu->bgBuffers[0].data[kPpuLineBorder+100]!=0x8001 || + ppu->bgBuffers[0].data[kPpuLineBorder-10]!=0x8002 || + ppu->bgBuffers[0].data[kPpuLineBorder+260]!=0x8002) { fprintf(stderr,"Mode2 world provider: calls=%u h=%x y=%x pixels=%x/%x/%x\n", test_shadow_calls,test_shadow_hscroll,test_shadow_y, - ppu->bgBuffers[0].data[kPpuExtraLeftRight+100], - ppu->bgBuffers[0].data[kPpuExtraLeftRight-10], - ppu->bgBuffers[0].data[kPpuExtraLeftRight+260]); + ppu->bgBuffers[0].data[kPpuLineBorder+100], + ppu->bgBuffers[0].data[kPpuLineBorder-10], + ppu->bgBuffers[0].data[kPpuLineBorder+260]); return 1; } return 0; diff --git a/tests/ppu/ppu_sprite_limit_test.c b/tests/ppu/ppu_sprite_limit_test.c index b1653506..ad897558 100644 --- a/tests/ppu/ppu_sprite_limit_test.c +++ b/tests/ppu/ppu_sprite_limit_test.c @@ -239,14 +239,14 @@ int main(void) { ppu_runLine(ppu, 0); ppu_runLine(ppu, 1); failures += check(ppu->timeOver, "34-sliver overflow is reported"); - failures += check((ppu->objBuffer.data[kPpuExtraLeftRight] & 0xff) == 0, + failures += check((ppu->objBuffer.data[kPpuLineBorder] & 0xff) == 0, "reverse fetch drops low slot after sliver overflow"); PpuBeginDrawing(ppu, pixels, kPitch, kPpuRenderFlags_NewRenderer | kPpuRenderFlags_NoSpriteLimits); render_one_line(ppu); - failures += check((ppu->objBuffer.data[kPpuExtraLeftRight] & 0xff) != 0, + failures += check((ppu->objBuffer.data[kPpuLineBorder] & 0xff) != 0, "disabling sprite limits renders the low slot"); /* Mode 2 OPT must still treat x=0 as the hardware left edge when @@ -309,7 +309,7 @@ int main(void) { ppu_runLine(ppu, 1); failures += check(count_argb_pixels(obj_pixels, 8) == 8, "default OBJ capture exports selected pixels"); - failures += check((ppu->objBuffer.data[kPpuExtraLeftRight] & 0xff) == 0, + failures += check((ppu->objBuffer.data[kPpuLineBorder] & 0xff) == 0, "RemoveFromGame drops captured OBJ from frame"); memset(frame_pixels, 0, sizeof frame_pixels); @@ -334,7 +334,7 @@ int main(void) { ppu_runLine(ppu, 1); failures += check(count_argb_pixels(obj_pixels, 8) == 0, "narrowed OBJ capture excludes other slots"); - failures += check((ppu->objBuffer.data[kPpuExtraLeftRight] & 0xff) != 0, + failures += check((ppu->objBuffer.data[kPpuLineBorder] & 0xff) != 0, "excluded OBJ remains in frame"); failures += check(PpuBindOverlaySurface( ppu, kPpuOverlaySource_Obj, NULL, 0), diff --git a/tests/ppu/ppu_view_anchor_test.c b/tests/ppu/ppu_view_anchor_test.c index cee542ca..8d732abf 100644 --- a/tests/ppu/ppu_view_anchor_test.c +++ b/tests/ppu/ppu_view_anchor_test.c @@ -180,9 +180,9 @@ static void setup_scene(Ppu *ppu, int layers) { * at all" (the plain symmetric border); otherwise left/right are published with * anchoring in the requested state. Rows are `width + kSlack` columns apart; * the renderer must never touch the slack. Returns the rendered row. */ -static uint32_t *render(Ppu *ppu, uint32_t *buf, int width, int budget, - int anchor, int left, int right, int layers, - bool hud_split, int line) { +static uint32_t *render_at(Ppu *ppu, uint32_t *buf, int width, int budget, + int anchor, int left, int right, int layers, + bool hud_split, int line, int scroll) { const size_t stride = (size_t)(width + kSlack); memset(buf, 0, sizeof(uint32_t) * stride * kRows); ppu_reset(ppu); @@ -203,13 +203,20 @@ static uint32_t *render(Ppu *ppu, uint32_t *buf, int width, int budget, * room-boundary pillarbox and not what this test is about. */ PpuSetWidescreenHudAlwaysVisible(ppu, true); } - ppu->hScroll[0] = kScroll; + ppu->hScroll[0] = scroll; ppu_runLine(ppu, 0); - ppu->hScroll[0] = kScroll; + ppu->hScroll[0] = scroll; ppu_runLine(ppu, line); return buf + stride * (size_t)(line - 1); } +static uint32_t *render(Ppu *ppu, uint32_t *buf, int width, int budget, + int anchor, int left, int right, int layers, + bool hud_split, int line) { + return render_at(ppu, buf, width, budget, anchor, left, right, layers, + hud_split, line, kScroll); +} + /* Nothing may be written past the frame's last column. */ static void check_slack(const uint32_t *row, int width, const char *label) { for (int i = 0; i < kSlack; i++) @@ -265,6 +272,64 @@ static void check_case(Ppu *ppu, uint32_t *buf, const uint32_t *ref, int left, check_slack(out, kWide, label); } +/* The oracle uses ordinary 256px renders at the required world scrolls. + * Unlike a larger symmetric oracle, it does not need the new buffer reach + * and can expose truncation at the old per-side capacity. */ +static void check_ultrawide(Ppu *ppu, const uint32_t *ref_hud) { + enum { max_width = 800, max_budget = (max_width - 256) / 2 }; + const int budgets[] = {214, max_budget}; + uint32_t *buf = calloc((max_width + kSlack) * kRows, sizeof(uint32_t)); + uint32_t *native = calloc((256 + kSlack) * kRows, sizeof(uint32_t)); + uint32_t expected[max_width]; + if (!buf || !native) { + check(false, "ultrawide: allocation succeeds"); + free(buf); + free(native); + return; + } + for (size_t b = 0; b < sizeof budgets / sizeof *budgets; b++) { + const int budget = budgets[b], span = budget * 2, width = 256 + span; + const int lefts[] = {0, span / 4, budget, span}; + for (size_t c = 0; c < sizeof lefts / sizeof *lefts; c++) { + const int left = lefts[c], right = span - left; + for (int col = 0; col < width; col += 256) { + const uint32_t *chunk = render_at( + ppu, native, 256, 0, -1, 0, 0, kBg1, false, + kWorldLine, kScroll + col - left); + const int count = width - col < 256 ? width - col : 256; + memcpy(expected + col, chunk, (size_t)count * sizeof(uint32_t)); + } + const uint32_t *out = render(ppu, buf, width, budget, 1, left, + right, kBg1, false, kWorldLine); + check(ppu->extraLeftCur == left && ppu->extraRightCur == right && + ppu->extraFrameLeft == left && ppu->extraFrameRight == right, + "ultrawide: the complete allowance can move to either side"); + check(memcmp(out, expected, (size_t)width * sizeof(uint32_t)) == 0, + "ultrawide: every column matches independent native renders"); + check_slack(out, width, "ultrawide world"); + + out = render(ppu, buf, width, budget, 1, left, right, kBg3, true, + kHudLine); + int bad = 0; + for (int x = 0; x < 256; x++) { + int dst = x < kHudLeftEnd ? x : + x >= kHudRightStart ? x + span : x + budget; + if (out[dst] != ref_hud[x + kRefExtra]) bad++; + } + check(bad == 0, "ultrawide: HUD chunks retain their screen positions"); + check_slack(out, width, "ultrawide HUD"); + } + } + PpuSetExtraSpace(ppu, UINT16_MAX); + check(ppu->extraLeftRight == max_budget, + "larger priority buffers do not raise the 800px output limit"); + PpuSetExtraSpaceCentered(ppu, UINT16_MAX); + check(ppu->extraLeftRight == max_budget, + "centered output retains the same maximum frame budget"); + free(buf); + free(native); +} + int main(void) { Ppu *ppu = ppu_init(); if (!ppu) return 2; @@ -504,6 +569,8 @@ int main(void) { offsetof(Ppu, cgram), "view-anchor storage lies outside both PPU savestate spans"); + check_ultrawide(ppu, ref_hud); + free(refbuf); free(refbuf_hud); free(buf); From 9a0c96eba07493e4e128cdf349332e6d094b5393 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 18:28:36 -0700 Subject: [PATCH 15/17] Retain producer sprite coordinates for wide anchored views --- docs/WIDESCREEN_PATTERNS.md | 14 ++++++++ runner/src/debug_server.c | 26 +++++++++++++++ runner/src/desktop/host_main.c | 2 +- runner/src/snes/cx4.c | 19 +++++++++++ runner/src/snes/cx4.h | 10 ++++++ runner/src/snes/dma.c | 11 +++++++ runner/src/snes/dma.h | 6 ++++ runner/src/snes/ppu.c | 26 +++++++++++++-- runner/src/snes/ppu.h | 19 ++++++++--- tests/cx4_ram_observer_test.c | 55 +++++++++++++++++++++++++++++++ tests/ppu/ppu_sprite_limit_test.c | 39 ++++++++++++++++++++++ 11 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 tests/cx4_ram_observer_test.c diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index dfa5c7d7..d832dae5 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -302,6 +302,20 @@ uint16 WsOamXReject(uint16 x_plus_16, uint16 widened_limit) { The PPU's 9-bit OAM X path already renders negative and 256+ coordinates. +For anchored views wider than512 columns, nine bits cannot identify every +screen coordinate. X3 retains the full X computed by its original HG51B +microcode through `cx4_set_ram_write_observer`, then attaches it to the actual +OAM XY upload through `dma_set_oam_xy_notify_hook` and `PpuWsSetOamFullX`. +The PPU uses it only when explicitly enabled and its low nine bits still +match guest OAM. Ordinary XY writes invalidate it. Titles must serialize both +pending producer coordinates and uploaded coordinates in their state extension, +which also participates in rollback. This observes original execution; it does +not replace the sprite builder or change guest OAM, camera or sequence state. + +Keep the viewport budget at least16 bits through the host-to-PPU call. A +272-column budget cast to eight bits became16 and collapsed800px output to288. +The built X3 regression checks actual684/800px images, not just buffer sizes. + **Check:** an enemy must remain drawn while straddling both margin edges, not just the right one. diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 33efbe57..6b946a61 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -5516,6 +5516,31 @@ static void cmd_cx4_state(const char *args) { send(s_client_sock, "]}\n", 3, 0); } +/* dump_cx4 [CPU_address_hex] [length_decimal]: inspect the live CX4 RAM/IO + * window without clocking or writing the device. Useful when OAM DMA is + * sourced from the coprocessor rather than WRAM. */ +static void cmd_dump_cx4(const char *args) { + if (!g_snes || !g_snes->cart || !g_snes->cart->cx4) { + send_fmt("{\"error\":\"cx4 not available\"}"); return; + } + unsigned int addr = 0x6000, len = 0xc00; + sscanf(args, "%x %u", &addr, &len); + if (addr < 0x6000 || addr >= 0x8000 || len > 0x2000 || + (uint64_t)addr + len > 0x8000) { + send_fmt("{\"error\":\"out of range\"}"); return; + } + if (s_client_sock == SOCKET_INVALID) return; + uint8_t bytes[0x2000]; + for (unsigned int i = 0; i < len; i++) + bytes[i] = cx4_read(g_snes->cart->cx4, (uint16_t)(addr + i)); + char hdr[96]; + int count = snprintf(hdr, sizeof(hdr), + "{\"addr\":\"0x%x\",\"len\":%u,\"hex\":\"", addr, len); + send(s_client_sock, hdr, count, 0); + send_hex_blob(bytes, len); + send_line("\"}"); +} + static void cmd_get_superfx_state(const char *args) { if (!g_snes || !g_snes->cart || !g_snes->cart->superfx) { send_fmt("{\"error\":\"superfx not available\"}"); @@ -8628,6 +8653,7 @@ static const CmdEntry s_commands[] = { {"get_v2_cpu", cmd_get_v2_cpu}, {"get_superfx_state", cmd_get_superfx_state}, {"cx4_state", cmd_cx4_state}, + {"dump_cx4", cmd_dump_cx4}, {"trace_dump", cmd_trace_dump}, {"trace_dbpb", cmd_trace_dbpb}, {"trace_clear", cmd_trace_clear}, diff --git a/runner/src/desktop/host_main.c b/runner/src/desktop/host_main.c index cccd9434..8324260c 100644 --- a/runner/src/desktop/host_main.c +++ b/runner/src/desktop/host_main.c @@ -724,7 +724,7 @@ static void PreparePpuFrame(void) { if (g_ws_active) flags |= kPpuRenderFlags_NewRenderer; PpuBeginDrawing(g_ppu, g_my_pixels, (g_game->native_widescreen ? fw : 256) * 4, flags); - PpuSetExtraSpace(g_ppu, (uint8)g_ws_extra); + PpuSetExtraSpace(g_ppu, (uint16_t)g_ws_extra); } // --- Scripted input --- diff --git a/runner/src/snes/cx4.c b/runner/src/snes/cx4.c index 7e2f1392..e6a585fc 100644 --- a/runner/src/snes/cx4.c +++ b/runner/src/snes/cx4.c @@ -111,6 +111,10 @@ struct Cx4 { uint32_t frac; /* master->Cx4 rate-conversion remainder */ int in_dsp; /* re-entrancy guard: we are inside cx4_run */ + Cx4RamWriteObserver ram_observer; + void *ram_observer_context; + uint32_t observer_instruction; + /* ── observability ────────────────────────────────────────────────── */ Cx4RunEvent run_ring[kCx4RunRingEntries]; uint32_t run_seq; @@ -449,6 +453,8 @@ static void cx4_execute_opcode(Cx4 *c, uint16_t opcode); static void cx4_execute(Cx4 *c) { if (!cx4_cache(c)) { cx4_halt(c); return; } const uint16_t opcode = c->programRAM[c->io.cache.page][c->r.pc]; + if (c->ram_observer) + c->observer_instruction = c->io.cache.address[c->io.cache.page] + c->r.pc * 2u; cx4_advance(c); cx4_step(c, 1); c->insns++; @@ -503,6 +509,9 @@ static void insn_wrram(Cx4 *c, unsigned byte, uint32_t addr) { uint32_t address = addr & M12; if (address >= 0xc00) address -= 0x400; c->dataRAM[address] = (uint8_t)((c->r.ram >> (byte * 8u)) & M8); + if (c->ram_observer) + c->ram_observer(c->ram_observer_context, c->observer_instruction, + (uint16_t)address, byte, c->r.ram); } static void insn_rdrom(Cx4 *c, uint32_t index) { @@ -777,6 +786,9 @@ static void cx4_dram_write(Cx4 *c, uint32_t address, uint8_t data) { address &= 0xfff; if (address >= 0xc00) return; c->dataRAM[address] = data; + if (c->ram_observer) + c->ram_observer(c->ram_observer_context, UINT32_MAX, + (uint16_t)address, 0, data); } /* The bus the DSP itself reads/writes through. */ @@ -960,6 +972,13 @@ Cx4 *cx4_create(const uint8_t *rom, uint32_t rom_size, void cx4_destroy(Cx4 *c) { free(c); } +void cx4_set_ram_write_observer(Cx4 *c, Cx4RamWriteObserver observer, + void *context) { + if (!c) return; + c->ram_observer = observer; + c->ram_observer_context = context; +} + void cx4_reset(Cx4 *c) { if (!c) return; memset(&c->r, 0, sizeof(c->r)); diff --git a/runner/src/snes/cx4.h b/runner/src/snes/cx4.h index 589fc7ac..f0fe9f3e 100644 --- a/runner/src/snes/cx4.h +++ b/runner/src/snes/cx4.h @@ -40,6 +40,16 @@ typedef struct Cx4 Cx4; struct SaveLoadInfo; +/* Presentation observers see the original DSP's untruncated RAM register. + * instruction is its cartridge byte address, or UINT32_MAX for bus writes. + * Callbacks must not modify the device or guest memory. Host wiring is not + * serialized; a title retaining derived metadata must snapshot that metadata. */ +typedef void (*Cx4RamWriteObserver)(void *context, uint32_t instruction, + uint16_t address, unsigned byte, + uint32_t value); +void cx4_set_ram_write_observer(Cx4 *cx4, Cx4RamWriteObserver observer, + void *context); + /* rom/rom_size is the cartridge image in the engine's linear LoROM layout; * ram/ram_size is cartridge save RAM (may be NULL/0). Neither is owned. */ Cx4 *cx4_create(const uint8_t *rom, uint32_t rom_size, diff --git a/runner/src/snes/dma.c b/runner/src/snes/dma.c index 9fb66396..e677dd0e 100644 --- a/runner/src/snes/dma.c +++ b/runner/src/snes/dma.c @@ -15,6 +15,7 @@ extern Ppu *g_ppu; static DmaVramNotifyHook g_vram_notify_hook; +static DmaOamXyNotifyHook g_oam_xy_notify_hook; /* Debug logging gate: set SNESRECOMP_DMA_DEBUG=1 to enable the DMA config / * CGRAM / S-DD1 arm trace. Off by default — the trace fires for every DMA @@ -29,6 +30,10 @@ void dma_set_vram_notify_hook(DmaVramNotifyHook hook) { g_vram_notify_hook = hook; } +void dma_set_oam_xy_notify_hook(DmaOamXyNotifyHook hook) { + g_oam_xy_notify_hook = hook; +} + static const int bAdrOffsets[8][4] = { {0, 0, 0, 0}, {0, 1, 0, 1}, @@ -335,7 +340,13 @@ static void dma_transferByte(Dma* dma, uint16_t aAdr, uint8_t aBank, uint8_t bAd } } debug_server_on_reg_write((uint16_t)(0x2100u + bAdr), val); + Ppu *ppu = dma->snes->ppu; + const int xy_slot = bAdr == 4 && g_oam_xy_notify_hook && + !ppu->oamInHigh && ppu->oamSecondWrite && !(ppu->oamAdr & 1) + ? ppu->oamAdr >> 1 : -1; snes_writeBBus(dma->snes, bAdr, val); + if (xy_slot >= 0) + g_oam_xy_notify_hook(aBank, aAdr, (uint8_t)xy_slot); } #ifdef SNESRECOMP_INTERP_PROFILE { extern uint64_t dma_prof_bytes; extern double dma_prof_ms; diff --git a/runner/src/snes/dma.h b/runner/src/snes/dma.h index f55baf89..3da9fb24 100644 --- a/runner/src/snes/dma.h +++ b/runner/src/snes/dma.h @@ -14,6 +14,12 @@ typedef void (*DmaVramNotifyHook)(uint8_t aBank, uint16_t aAdr, uint16_t vmadd, uint16_t size); void dma_set_vram_notify_hook(DmaVramNotifyHook hook); +/* Called after an OAM XY word is committed, with the source of its Y byte. + * This observes actual DMA/HDMA transfers, including partial/relocated uploads; + * the hook must not change guest memory or DMA state. */ +typedef void (*DmaOamXyNotifyHook)(uint8_t aBank, uint16_t yAdr, uint8_t slot); +void dma_set_oam_xy_notify_hook(DmaOamXyNotifyHook hook); + #include "snes.h" typedef struct DmaChannel { diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 8690d9ab..d1ef185a 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -310,6 +310,7 @@ static inline void PpuResetLayerPolicies(Ppu *ppu) { ppu->wsWindowExpandLayers = 0; ppu->wsWindowExpandWindows = 0; ppu->wsViewAnchor = 0; + ppu->wsOamFullXOn = 0; } int PpuWsExtraOverride(void) { @@ -2954,12 +2955,27 @@ static int PpuAdjustWidescreenHudOamX(Ppu *ppu, uint8_t index, uint8_t y, /* OAM X is 9 bits, so the reachable span is 512 columns: raw >= 256 + * extraRightCur decodes negative, which puts the deepest reachable left column * at -(256 - extraRightCur). A frame whose LIVE margins total more than 256 - * therefore has a far-side sliver no sprite can be placed in -- nothing is - * mis-decoded, it is simply unaddressable. Only view anchoring can produce - * that, and only above a 128 px budget; see PpuSetWidescreenViewAnchor. */ + * therefore needs producer-retained full coordinates for its far-side span. + * Full coordinates are used only when explicitly enabled and still agree + * with the guest's current nine-bit position; otherwise decode normally. */ +void PpuWsSetOamFullX(Ppu *ppu, unsigned slot, int16_t x) { + if (!ppu || slot >= 128) return; + ppu->wsOamFullX[slot] = x; + ppu->wsOamFullXValid[slot >> 3] |= 1u << (slot & 7); +} + +void PpuWsEnableOamFullX(Ppu *ppu, bool enabled) { + if (ppu) ppu->wsOamFullXOn = enabled ? 1 : 0; +} + static int PpuDecodeOamX(Ppu *ppu, uint8_t index) { int x = ppu->oam[index] & 0xff; x |= ((ppu->highOam[index >> 3] >> (index & 7)) & 1) << 8; + unsigned slot = index >> 1; + if (ppu->wsOamFullXOn && + (ppu->wsOamFullXValid[slot >> 3] & (1u << (slot & 7))) && + (ppu->wsOamFullX[slot] & 511) == x) + return ppu->wsOamFullX[slot]; if (x >= 256 + ppu->extraRightCur) { x -= 512; } else if (x >= 256 && ppu->wsOamRightHintStrict) { @@ -3719,6 +3735,10 @@ void ppu_write(Ppu* ppu, uint8_t adr, uint8_t val) { } else { uint16_t widx = ppu->oamAdr; uint16_t word = (uint16_t)((val << 8) | ppu->oamBuffer); + if (!(widx & 1)) { + unsigned slot = widx >> 1; + ppu->wsOamFullXValid[slot >> 3] &= ~(1u << (slot & 7)); + } ppu->oam[ppu->oamAdr++] = word; ppu->oamWriteCount++; debug_server_on_oam_write(0, widx, word); diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index 2ab1cc90..2afde2f0 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -344,6 +344,12 @@ struct Ppu { // Games publish per NMI via PpuWsSetOamRightHints. 1 bit per OAM slot. uint8_t wsOamRightHintStrict; uint8_t wsOamRightHint[16]; + /* Coordinates retained from a title's original sprite producer, tied to + * actual XY uploads. Ordinary XY writes invalidate the corresponding slot. + * Host policy opts into decoding; guest OAM always remains nine bits. */ + uint8_t wsOamFullXOn; + int16_t wsOamFullX[128]; + uint8_t wsOamFullXValid[16]; /* Host-only temporal classifier for unhinted left-margin OBJ. It lets a * game-authored sprite keep moving through the widened margin, then parks it * again after its X stops changing for a few frames. */ @@ -826,10 +832,9 @@ void PpuSetExtraSideSpace(Ppu *ppu, int left, int right, int bottom); // sprite to be placeable anywhere in the frame. Anchoring can put the whole // 2*budget allowance on one side, so that total is 2*budget: a budget above // 128 px per side (an internal width above 512) leaves a far-margin sliver -// no sprite can reach. Backgrounds are unaffected, nothing is MIS-placed, -// and every shipping budget (43 px MMX, 71 px SMW 16:9) is far inside it, -// so this is documented rather than clamped -- clamping would throw away -// real background columns to spare a sprite artefact. +// no ordinary nine-bit sprite can reach. Titles retaining coordinates from +// their original sprite producer may use PpuWsSetOamFullX to resolve this +// ambiguity without changing guest OAM or restricting background columns. // - The output budget is capped at kPpuMaxFrameExtra, while the priority // buffers carry twice that many columns per side. Every supported frame // can therefore put its full expansion on either side without padding. @@ -894,6 +899,12 @@ void PpuWsSetOamLeftHints(Ppu *ppu, const uint8_t *hints); // buffer is final and before the frame is presented. void PpuWsSetOamRightHints(Ppu *ppu, const uint8_t *hints); +/* Publish after the corresponding XY word upload. A title using this API + * must include fullX/valid in its title save-state extension, which is also + * part of rollback snapshots. Enabling is a per-frame layer policy. */ +void PpuWsSetOamFullX(Ppu *ppu, unsigned slot, int16_t x); +void PpuWsEnableOamFullX(Ppu *ppu, bool enabled); + // Let BG3 (layer 2) render into the widescreen side margins on scanlines // >= from_y, instead of being clamped to the authentic 256-wide region. Pass // the HUD band height so the status bar above it stays clamped (or split) while diff --git a/tests/cx4_ram_observer_test.c b/tests/cx4_ram_observer_test.c new file mode 100644 index 00000000..591a74d8 --- /dev/null +++ b/tests/cx4_ram_observer_test.c @@ -0,0 +1,55 @@ +/* Synthetic HG51B execution: observe full values without replacing microcode. */ +#include +#include +#include +#include +#include "snes/cx4.h" + +static unsigned calls; +static uint32_t instruction, value; +static uint16_t address; +static unsigned lane; +static void observed(void *context, uint32_t pc, uint16_t addr, + unsigned byte, uint32_t full) { + assert(context == &calls); + calls++; instruction=pc; address=addr; lane=byte; value=full; +} + +static void run(Cx4 *c) { + cx4_write(c,0x7f49,0);cx4_write(c,0x7f4a,0x80);cx4_write(c,0x7f4b,0); + cx4_write(c,0x7f4f,0); + for (uint64_t clock=128;clock<65536;clock+=128) { + cx4_sync(c,clock); + if (!(cx4_read(c,0x7f5e)&0xc0)) return; + } + assert(!"synthetic DSP did not halt"); +} + +int main(void) { + uint8_t rom[32768]={0}; + /* LD A,3; SHL A,8; ST RAM,A; LD A,0; WRRAM byte0,A; HALT. */ + const uint16_t positive[]={0x6403,0xdc08,0xe00c,0x6400,0xe800,0xfc00}; + for (unsigned i=0;i<6;i++) { + rom[i*2]=(uint8_t)positive[i];rom[i*2+1]=(uint8_t)(positive[i]>>8); + } + Cx4 *c=cx4_create(rom,sizeof rom,NULL,0);assert(c); + cx4_set_ram_write_observer(c,observed,&calls); + run(c); + assert(calls==1 && instruction==0x8008 && address==0 && lane==0 && value==768); + assert(cx4_read(c,0x6000)==0); /* Guest still sees only the written byte. */ + cx4_write(c,0x6000,0x52); + assert(calls==2 && instruction==UINT32_MAX && value==0x52); + assert(cx4_read(c,0x6000)==0x52); + cx4_set_ram_write_observer(c,NULL,NULL); + cx4_write(c,0x6000,0x34);assert(calls==2); + cx4_reset(c); + /* LD A,0; SUB A,1 gives the original 24-bit signed -1. */ + rom[0]=0;rom[1]=0x64;rom[2]=1;rom[3]=0x94; + cx4_set_ram_write_observer(c,observed,&calls); + run(c); + assert(calls==3 && instruction==0x8008 && value==0xffffff); + assert(cx4_read(c,0x6000)==0xff); + cx4_destroy(c); + puts("cx4_ram_observer_test: PASS"); + return 0; +} diff --git a/tests/ppu/ppu_sprite_limit_test.c b/tests/ppu/ppu_sprite_limit_test.c index ad897558..c50f9497 100644 --- a/tests/ppu/ppu_sprite_limit_test.c +++ b/tests/ppu/ppu_sprite_limit_test.c @@ -805,6 +805,45 @@ int main(void) { ; } + /* Original producer coordinates survive the nine-bit OAM wrap. Verify + * actual pixels with both renderers, at either full-width view anchor. */ + for (unsigned flags=0; flags<=1; flags++) { + const int positions[]={-900,-530,-300,-16,0,255,512,700,790,900}; + for (unsigned side=0; side<2; side++) { + for (unsigned i=0; i=x+origin && coloam[0]&255)==(x&255), + "full X preserves guest OAM"); + } + } + ppu_reset(ppu); + setup_one_sprite_line(ppu,700&511); + PpuWsSetOamFullX(ppu,0,700); + ppu_write(ppu,2,0);ppu_write(ppu,3,0); + ppu_write(ppu,4,120);ppu_write(ppu,4,0); + failures+=check(!(ppu->wsOamFullXValid[0]&1), + "an ordinary XY write invalidates the previous full X"); + PpuWsEnableOamFullX(ppu,true); + PpuSetExtraSpace(ppu,43); + failures+=check(!ppu->wsOamFullXOn, + "frame policy reset disables full-coordinate decoding"); + } + ppu_free(ppu); if (failures) return 1; puts("ppu_sprite_limit_test: PASS"); From 3e2e56737c2494bc7cce8e765948973068d8b2d7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 19:43:17 -0700 Subject: [PATCH 16/17] Keep widescreen margin sprites outside native OBJ quotas --- docs/WIDESCREEN_PATTERNS.md | 11 +++++++++ runner/src/snes/ppu.c | 33 ++++++++++++++++++--------- tests/ppu/ppu_sprite_limit_test.c | 37 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index d832dae5..5e06f9e3 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -302,6 +302,17 @@ uint16 WsOamXReject(uint16 x_plus_16, uint16 widened_limit) { The PPU's 9-bit OAM X path already renders negative and 256+ coordinates. +Margin-only objects and slivers must not consume the native32-object and +34-sliver budgets. Account in the original screen coordinates before HUD +relocation, then render additional margin work in OAM order. Genuine native +overflow and the explicit NoSpriteLimits option retain their behavior; native +views also retain their early exit. Otherwise adding margin residency can +erase native sprite pixels and set guest-visible overflow flags. X3's busy +final corridor exposed this: three native OBJ pixels disappeared and both +overflow flags were raised solely because the wider margins were active. +The synthetic margin-quota cases fail on the old evaluator in both renderers; +the fixed evaluator matches a zero-extra replay of12 captured corridor inputs. + For anchored views wider than512 columns, nine bits cannot identify every screen coordinate. X3 retains the full X computed by its original HG51B microcode through `cx4_set_ram_write_observer`, then attaches it to the actual diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index d1ef185a..910ddca1 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -3125,6 +3125,9 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { unsigned first_slot=index>>1; int spritesFound = 0; int tilesFound = 0; + int nativeSpritesFound = 0; + int nativeTilesFound = 0; + const bool extra_view = ppu->extraLeftRight != 0; uint8_t foundSprites[128]; // Range evaluation walks OAM forward, but tile fetching walks the accepted @@ -3136,6 +3139,7 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { int spriteHeight = PPU_objInterlace(ppu) ? spriteSize / 2 : spriteSize; if(row < spriteHeight) { int x = PpuDecodeOamX(ppu, index); + const bool native_sprite = x + spriteSize > 0 && x < 256; const int left_extra = PpuWidescreenHudOamSlot(ppu, index, y) && (ppu->wsHudSplitHeight & 0x80) @@ -3145,14 +3149,16 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { left_extra)) { x = PpuAdjustWidescreenHudOamX(ppu, index, y, x); if(x + spriteSize > -left_extra) { - spritesFound++; - if(spritesFound > 32 && + /* Margin residency must not evict an authentic native sprite or + * leak widened range-over flags into the guest. Count before HUD + * relocation so moving the HUD does not free its native quota. */ + if(native_sprite && ++nativeSpritesFound > 32 && !(ppu->renderFlags & kPpuRenderFlags_NoSpriteLimits)) { ppu->rangeOver = true; - spritesFound = 32; - break; + if (!extra_view) break; + } else { + foundSprites[spritesFound++] = index; } - foundSprites[spritesFound - 1] = index; } } } @@ -3164,6 +3170,7 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { uint8_t row = line - (ppu->oam[index] >> 8); int spriteSize = spriteSizes[PPU_objSize(ppu)][(ppu->highOam[index >> 3] >> ((index & 7) + 1)) & 1]; int x = PpuDecodeOamX(ppu, index); + const int native_x = x; const uint8_t sprite_y = ppu->oam[index] >> 8; const bool full_budget_hud = PpuWidescreenHudOamSlot(ppu, index, sprite_y) && @@ -3185,12 +3192,18 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { if(col + x <= -8 - left_extra || col + x >= 256 + right_extra) continue; - tilesFound++; - if(tilesFound > 34 && + /* The original 34-sliver fetch budget covers the native view. + * Additional margin columns render in OAM order without taking + * that budget from native columns. Genuine native overflow still + * drops later native fetches, including within a large sprite. */ + const bool native_tile = col + native_x > -8 && col + native_x < 256; + if(native_tile && ++nativeTilesFound > 34 && !(ppu->renderFlags & kPpuRenderFlags_NoSpriteLimits)) { ppu->timeOver = true; - break; + if (!extra_view) break; + continue; } + tilesFound++; int usedCol = oam1 & 0x4000 ? spriteSize - 1 - col : col; int usedTile = ((((oam1 & 0xff) >> 4) + (row >> 3)) << 4) | (((oam1 & 0xf) + (usedCol >> 3)) & 0xf); @@ -3269,8 +3282,8 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { ppu->objectOrder[dst-ppu->objBuffer.data]=(uint64_t)((slot-first_slot)&127)<<32; } } - if(tilesFound > 34 && - !(ppu->renderFlags & kPpuRenderFlags_NoSpriteLimits)) + if (!extra_view && nativeTilesFound > 34 && + !(ppu->renderFlags & kPpuRenderFlags_NoSpriteLimits)) break; } bool extra=ppu->extraObjectCount && PpuComposeExtraObjects( diff --git a/tests/ppu/ppu_sprite_limit_test.c b/tests/ppu/ppu_sprite_limit_test.c index c50f9497..e84b7e62 100644 --- a/tests/ppu/ppu_sprite_limit_test.c +++ b/tests/ppu/ppu_sprite_limit_test.c @@ -805,6 +805,43 @@ int main(void) { ; } + /* Extra margin work must not consume the authentic32/34 OBJ quotas. + * Both renderers share the evaluator, and genuine native overflow stays. */ + for (unsigned flags=0;flags<=1;flags++) { + uint32_t wide[512]; + ppu_reset(ppu);setup_one_sprite_line(ppu,0); + for (unsigned slot=0;slot<32;slot++)setup_solid_obj(ppu,slot,-32,0); + setup_solid_obj(ppu,32,64,0); + PpuBeginDrawing(ppu,(uint8_t *)wide,342*4,flags); + PpuSetExtraSpace(ppu,43);render_one_line(ppu); + failures+=check((wide[43+64]&0xffffff)==0xffffff, + "32 margin sprites cannot discard a native sprite"); + failures+=check((wide[43-32]&0xffffff)==0xffffff, + "margin sprites still render without consuming native range quota"); + failures+=check(!ppu->rangeOver,"margin range does not assert native overflow"); + + ppu_reset(ppu);setup_one_sprite_line(ppu,0);ppu->obsel=2<<5; + for (unsigned slot=1;slot<=5;slot++) { + setup_solid_obj(ppu,slot,-96,0); + ppu->highOam[slot>>2]|=1u<<((slot&3)*2+1); + } + PpuBeginDrawing(ppu,(uint8_t *)wide,sizeof wide,flags); + PpuSetExtraSpace(ppu,128);render_one_line(ppu); + failures+=check((wide[128]&0xffffff)==0xffffff, + "40 margin slivers cannot discard a native sliver"); + failures+=check((wide[128-96]&0xffffff)==0xffffff, + "margin slivers remain visible with native limits enabled"); + failures+=check(!ppu->timeOver,"margin fetch does not assert native overflow"); + + ppu_reset(ppu);setup_one_sprite_line(ppu,64); + for (unsigned slot=0;slot<32;slot++)setup_solid_obj(ppu,slot,64,0); + setup_solid_obj(ppu,32,0,0); + PpuBeginDrawing(ppu,(uint8_t *)wide,342*4,flags); + PpuSetExtraSpace(ppu,43);render_one_line(ppu); + failures+=check((wide[43]&0xffffff)==0 && ppu->rangeOver, + "genuine32-native-sprite overflow remains enforced"); + } + /* Original producer coordinates survive the nine-bit OAM wrap. Verify * actual pixels with both renderers, at either full-width view anchor. */ for (unsigned flags=0; flags<=1; flags++) { From 633cf3418ce7443cbcea2421746929dea6d753e8 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 27 Oct 2026 20:33:02 -0700 Subject: [PATCH 17/17] Clip bounded-room OAM without changing native pixels or HUD --- docs/WIDESCREEN_PATTERNS.md | 9 +++++ runner/src/snes/ppu.c | 38 +++++++++++++++------ runner/src/snes/ppu.h | 23 +++++++------ tests/ppu/ppu_sprite_limit_test.c | 57 +++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index 5e06f9e3..dc2dd095 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -114,6 +114,15 @@ floor pit or entrance from being duplicated outside the room. Width0 disables the band; larger widths are bounded by the authored span. As with reflection, the source strip must be in the rendered line. +For bounded travel rooms, obtain the outer canvas bounds from the loaded +layout, retaining transparent cells inside that span. Split the policy when +the visible frame crosses source rows with different bounds. Layer4 of +`PpuSetWidescreenLayerWorldClipBand[Slot]` clips guest OAM outside the same +room using the reference BG's actual per-line scroll. Native256 pixels, +configured HUD slots and authentic OBJ quotas remain intact; host extra +objects are independent. Use this with wall-edge continuation so projectiles +cannot appear beyond a closed room. The policy expires each frame. + Finite object canvases need a different source rule from scenery. X3's Maoh paints its BG2 body over a reused circular map: neighboring map entries still contain old level tiles. A complete map is not necessarily a complete authored diff --git a/runner/src/snes/ppu.c b/runner/src/snes/ppu.c index 910ddca1..a20b7a06 100644 --- a/runner/src/snes/ppu.c +++ b/runner/src/snes/ppu.c @@ -577,12 +577,12 @@ void PpuSetWidescreenLayerWorldMirrorBand(Ppu *ppu, uint8_t layer, uint8_t y0, world_left_px, world_right_px); } -void PpuSetWidescreenLayerWorldMirrorBandSlot( +static void PpuSetWidescreenWorldBandSlot( Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, uint16_t world_left_px, uint16_t world_right_px) { // See ppu.h. An empty band or an empty/inverted world span disables the // slot outright rather than leaving half-configured state behind. - if (slot >= kPpuWsWorldBands || layer >= 4) + if (slot >= kPpuWsWorldBands || layer >= 5) return; if (y1 <= y0 || world_right_px <= world_left_px) { y0 = y1 = 0; @@ -597,6 +597,14 @@ void PpuSetWidescreenLayerWorldMirrorBandSlot( ppu->wsWorldReference[slot][layer] = 0; } +void PpuSetWidescreenLayerWorldMirrorBandSlot( + Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, + uint16_t world_left_px, uint16_t world_right_px) { + if (layer < 4) + PpuSetWidescreenWorldBandSlot(ppu, slot, layer, y0, y1, + world_left_px, world_right_px); +} + void PpuSetWidescreenLayerWorldClipBand( Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, uint16_t world_left_px, uint16_t world_right_px) { @@ -609,9 +617,9 @@ void PpuSetWidescreenLayerWorldClipBandSlot( Ppu *ppu, uint8_t slot, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, uint16_t world_left_px, uint16_t world_right_px) { if (reference_layer >= 4) y0 = y1 = 0; - PpuSetWidescreenLayerWorldMirrorBandSlot(ppu, slot, layer, y0, y1, - world_left_px, world_right_px); - if (slot < kPpuWsWorldBands && layer < 4 && + PpuSetWidescreenWorldBandSlot(ppu, slot, layer, y0, y1, + world_left_px, world_right_px); + if (slot < kPpuWsWorldBands && layer < 5 && ppu->wsWorldY1[slot][layer] > ppu->wsWorldY0[slot][layer]) { ppu->wsWorldClip[slot] |= (uint8_t)(1u << layer); ppu->wsWorldReference[slot][layer] = (uint8_t)(reference_layer + 1); @@ -997,16 +1005,16 @@ static unsigned PpuWorldReferenceLayer(const Ppu *ppu, int band, uint layer) { // Keep the isolated compositor for disjoint rooms, mosaic and source anchors. static bool PpuWorldClipBounds(const Ppu *ppu, uint layer, int y, int *left, int *right) { - if (layer >= 4 || + if (layer >= 5 || !((ppu->wsWorldClip[0] | ppu->wsWorldClip[1]) & (1u << layer)) || - PPU_mode(ppu) != 1 || - (PPU_mosaicEnabled(ppu, layer) && PPU_mosaicSize(ppu) > 1)) + (layer < 4 && (PPU_mode(ppu) != 1 || + (PPU_mosaicEnabled(ppu, layer) && PPU_mosaicSize(ppu) > 1)))) return false; int band = PpuWidescreenLayerWorldBandAt(ppu, layer, y); if (band < 0 || !(ppu->wsWorldClip[band] & (1u << layer)) || PpuWidescreenLayerElasticBandAt(ppu, layer, y) >= 0) return false; - for (uint slot = 0; slot < kPpuWsAnchorBands; ++slot) { + for (uint slot = 0; layer < 4 && slot < kPpuWsAnchorBands; ++slot) { if (y >= ppu->wsAnchorY0[slot][layer] && y < ppu->wsAnchorY1[slot][layer] && ppu->wsAnchorLeftEnd[slot][layer] && @@ -3128,6 +3136,9 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { int nativeSpritesFound = 0; int nativeTilesFound = 0; const bool extra_view = ppu->extraLeftRight != 0; + int world_clip_left, world_clip_right; + const bool world_clip = PpuWorldClipBounds( + ppu, 4, line, &world_clip_left, &world_clip_right); uint8_t foundSprites[128]; // Range evaluation walks OAM forward, but tile fetching walks the accepted @@ -3172,9 +3183,9 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { int x = PpuDecodeOamX(ppu, index); const int native_x = x; const uint8_t sprite_y = ppu->oam[index] >> 8; + const bool hud_slot = PpuWidescreenHudOamSlot(ppu, index, sprite_y); const bool full_budget_hud = - PpuWidescreenHudOamSlot(ppu, index, sprite_y) && - (ppu->wsHudSplitHeight & 0x80); + hud_slot && (ppu->wsHudSplitHeight & 0x80); const int left_extra = full_budget_hud ? ppu->extraFrameLeft : ppu->extraLeftCur; const int right_extra = @@ -3211,6 +3222,11 @@ static bool ppu_evaluateSprites(Ppu* ppu, int line) { uint32 plane = addr[0] | addr[8] << 16; int px_left = IntMax(-(col + x + kPpuLineBorder), 0); int px_right = IntMin(256 + kPpuLineBorder - (col + x), 8); + if (world_clip && !hud_slot) { + px_left = IntMax(px_left, world_clip_left - col - x); + px_right = IntMin(px_right, world_clip_right - col - x); + if (px_left >= px_right) continue; + } PpuZbufType *dst = ppu->objBuffer.data + col + x + px_left + kPpuLineBorder; int slot = index >> 1; /* Clip an unhinted sprite at the SCREEN EDGE, per pixel. diff --git a/runner/src/snes/ppu.h b/runner/src/snes/ppu.h index 2afde2f0..6ada413e 100644 --- a/runner/src/snes/ppu.h +++ b/runner/src/snes/ppu.h @@ -300,15 +300,15 @@ struct Ppu { uint8_t wsAnchorRightStart[kPpuWsAnchorBands][4]; // Skip offscreen staging columns before sampling a layer's side margins. uint8_t wsMarginGapL[4], wsMarginGapR[4]; - // Authored-world spans, in tilemap pixels, for the world-mirror bands (see - // PpuSetWidescreenLayerWorldMirrorBand). y1<=y0 or right<=left = off. - uint8_t wsWorldY0[kPpuWsWorldBands][4], wsWorldY1[kPpuWsWorldBands][4]; - uint16_t wsWorldLeft[kPpuWsWorldBands][4]; - uint16_t wsWorldRight[kPpuWsWorldBands][4]; + // Authored-world spans in tilemap pixels. BG0..3 can mirror/repeat/clip; + // guest OAM4 can only clip. y1<=y0 or right<=left = off. + uint8_t wsWorldY0[kPpuWsWorldBands][5], wsWorldY1[kPpuWsWorldBands][5]; + uint16_t wsWorldLeft[kPpuWsWorldBands][5]; + uint16_t wsWorldRight[kPpuWsWorldBands][5]; uint8_t wsWorldClip[kPpuWsWorldBands]; /* layer bits: clip instead of reflect */ - uint16_t wsWorldRepeat[kPpuWsWorldBands][4]; /* 0=reflect, otherwise edge strip */ + uint16_t wsWorldRepeat[kPpuWsWorldBands][5]; /* 0=reflect, otherwise edge strip */ /* 0 keeps the layer's own phase; 1..4 selects BG1..BG4 as the reference. */ - uint8_t wsWorldReference[kPpuWsWorldBands][4]; + uint8_t wsWorldReference[kPpuWsWorldBands][5]; // Piecewise horizontal remaps for the elastic anchor bands (see // PpuSetWidescreenLayerElasticBand). nseg == 0 or y1 <= y0 = slot off. uint8_t wsElasticY0[kPpuWsElasticBands][4]; @@ -529,7 +529,7 @@ static inline int PpuWidescreenLayerExtra( return (ppu->wsBg3WidenY && y >= ppu->wsBg3WidenY) ? extra : 0; } -// Which world-mirror band, if any, governs BG(layer+1) on scanline y. +// Which world band, if any, governs BG0..3 or guest OAM4 on scanline y. // Returns the band slot, or -1 for natural (unreflected) tilemap rendering. // Deliberately inert unless a side margin is actually live, and every other // per-line policy wins over this one: on a line the layer does not widen @@ -538,7 +538,7 @@ static inline int PpuWidescreenLayerExtra( // a raster-split status bar pinned to hScroll 0 is the usual case. static inline int PpuWidescreenLayerWorldBandAt( const Ppu *ppu, unsigned int layer, int y) { - if (layer >= 4 || !(ppu->extraLeftCur | ppu->extraRightCur)) + if (layer >= 5 || !(ppu->extraLeftCur | ppu->extraRightCur)) return -1; int widest = ppu->extraLeftCur > ppu->extraRightCur ? ppu->extraLeftCur : ppu->extraRightCur; @@ -1017,7 +1017,10 @@ void PpuSetWidescreenLayerWorldMirrorBandSlot( // room. Only margin pixels outside [left,right), measured using the reference // layer's actual PPU scroll on this line, are omitted. Native256 columns and // genuine in-room margin art remain untouched. No canvas pixels are reflected. -// Reference/layer indices are0..3. Invalid inputs disable the slot. This is +// Reference indices are0..3. Layer4 additionally clips guest OAM, except +// explicitly selected HUD slots; it does not affect host extra objects or +// native OBJ range/fetch quotas. OAM uses the reference scroll in every mode. +// Invalid inputs disable the slot. This is // frame-local, outside save-state spans, and reset by PpuSetExtraSpace*. void PpuSetWidescreenLayerWorldClipBand( Ppu *ppu, uint8_t layer, uint8_t y0, uint8_t y1, uint8_t reference_layer, diff --git a/tests/ppu/ppu_sprite_limit_test.c b/tests/ppu/ppu_sprite_limit_test.c index e84b7e62..27a3ff30 100644 --- a/tests/ppu/ppu_sprite_limit_test.c +++ b/tests/ppu/ppu_sprite_limit_test.c @@ -881,6 +881,63 @@ int main(void) { "frame policy reset disables full-coordinate decoding"); } + + /* Bounded travel rooms clip world OAM at each raster's reference scroll, + * retaining native pixels, HUD relocation and the original OBJ quotas. */ + for (unsigned flags=0; flags<=1; ++flags) { + const int extras[]={0,43,272}; + const int positions[]={-32,-24,-8,0,248,256,272,280,288}; + for (unsigned e=0;ebgmode=7; + ppu_runLine(ppu,0); + for (int y=0;y<3;++y) { + const int scroll=y==2?649:640; + ppu->hScroll[0]=scroll; + ppu_runLine(ppu,y+1); + for (int col=0;col=8-extra && x<16-extra; + for (unsigned n=0;n=620-scroll || x>=0) && + (x<920-scroll || x<256)); + lit|=x>=positions[n] && xrangeOver && !ppu->timeOver, + "world OAM presentation does not change native overflow flags"); + } + } + } ppu_free(ppu); if (failures) return 1; puts("ppu_sprite_limit_test: PASS");