diff --git a/docs/WIDESCREEN_PATTERNS.md b/docs/WIDESCREEN_PATTERNS.md index 7fd371ca..dc2dd095 100644 --- a/docs/WIDESCREEN_PATTERNS.md +++ b/docs/WIDESCREEN_PATTERNS.md @@ -106,6 +106,35 @@ 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. + +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 +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 @@ -192,6 +221,30 @@ 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. 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 **Defect:** treating a horizontally periodic layer (sky, repeating city glow) as @@ -258,6 +311,31 @@ 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 +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/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..28a8369c --- /dev/null +++ b/docs/validation/mmx2-rocket-serges-margins-2026-10-26.md @@ -0,0 +1,74 @@ +# 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. + +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 + 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. +- 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 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/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/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/debug_server.c b/runner/src/debug_server.c index 69e15737..6b946a61 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); } @@ -5045,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; @@ -5070,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) { @@ -5126,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; @@ -5357,14 +5367,17 @@ 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\"," "\"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,\"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]}]," + "\"left\":[%u,%u,%u,%u],\"right\":[%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, @@ -5381,12 +5394,14 @@ 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: 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 +5410,10 @@ 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->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], p->wsWorldY0[1][3], p->wsWorldY1[1][0], p->wsWorldY1[1][1], p->wsWorldY1[1][2], @@ -5403,6 +5422,10 @@ 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->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, p->evenFrame ? "true" : "false"); } @@ -5493,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\"}"); @@ -8605,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 91ea98ea..8324260c 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; @@ -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 --- @@ -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/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/mmx23_background.c b/runner/src/snes/mmx23_background.c new file mode 100644 index 00000000..fa2362af --- /dev/null +++ b/runner/src/snes/mmx23_background.c @@ -0,0 +1,293 @@ +/* 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 +#include +#include +#include "cart.h" +#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; +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]; +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 */ + 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; + /* 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. 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 + * 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 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) { + return s_tile_values[cache]; + } + uint32_t x = tx * 8, y = ty * 8; + uint16_t entry = world_tile(stream, x, y); + s_tile_keys[cache] = key + 1; + 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; + /* 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; + 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; + 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; + 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); + 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 Mmx23ConfigureWsBgMarginsForRoom(Snes *snes, Ppu *ppu, uint8_t *ram, + bool enabled, uint16_t bg1RepeatRight, + bool bg2SourceCanvas) { + 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; + s_bg2_source_canvas = bg2SourceCanvas; + 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/ + * tile scroll to WsShadowTile; source validation still applies. */ + bool scene = on && enabled && snes && ppu && ram && + (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); + /* 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); + 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)); + } +} + +void Mmx23ConfigureWsBgMargins(Snes *snes, Ppu *ppu, uint8_t *ram, bool enabled) { + Mmx23ConfigureWsBgMarginsForRoom(snes,ppu,ram,enabled,0,false); +} diff --git a/runner/src/snes/mmx23_background.h b/runner/src/snes/mmx23_background.h new file mode 100644 index 00000000..3e0c5208 --- /dev/null +++ b/runner/src/snes/mmx23_background.h @@ -0,0 +1,17 @@ +#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); +/* Optional authored right boundary: repeat the preceding 256px of BG1 into + * 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, + bool bg2SourceCanvas); +#endif 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/ppu.c b/runner/src/snes/ppu.c index 850a7b02..a20b7a06 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) @@ -298,6 +298,9 @@ 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->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)); memset(ppu->wsElasticNSeg, 0, sizeof(ppu->wsElasticNSeg)); @@ -306,6 +309,8 @@ static inline void PpuResetLayerPolicies(Ppu *ppu) { memset(ppu->wsViewportInsetR, 0, sizeof(ppu->wsViewportInsetR)); ppu->wsWindowExpandLayers = 0; ppu->wsWindowExpandWindows = 0; + ppu->wsViewAnchor = 0; + ppu->wsOamFullXOn = 0; } int PpuWsExtraOverride(void) { @@ -313,27 +318,29 @@ 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. ppu->extraLeftRight = extra; ppu->extraLeftCur = extra; ppu->extraRightCur = extra; + ppu->extraFrameLeft = extra; + ppu->extraFrameRight = extra; PpuResetLayerPolicies(ppu); } 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 @@ -341,6 +348,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); } @@ -350,11 +359,46 @@ 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, kPpuLineBorder); + 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; + // 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; + 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 @@ -533,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; @@ -548,6 +592,57 @@ 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->wsWorldRepeat[slot][layer] = 0; + 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) { + 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; + 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); + } +} + +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) { @@ -666,19 +761,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 > 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; @@ -895,6 +995,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 >= 5 || + !((ppu->wsWorldClip[0] | ppu->wsWorldClip[1]) & (1u << layer)) || + (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; layer < 4 && 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 @@ -1047,9 +1206,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; @@ -1115,8 +1274,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; \ @@ -1130,6 +1289,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); @@ -1152,7 +1312,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]; @@ -1252,7 +1412,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]; @@ -1328,6 +1488,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 */ @@ -1342,7 +1503,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; @@ -1390,13 +1551,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 +1673,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)) @@ -1516,7 +1682,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 @@ -1546,10 +1712,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) @@ -1603,10 +1792,13 @@ 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 - // 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 @@ -1622,14 +1814,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); } @@ -1650,7 +1844,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]; @@ -1727,8 +1921,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) @@ -1755,8 +1949,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]; @@ -1799,37 +1993,35 @@ 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; + 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]; } } -// 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, +// 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, int strip) { PpuZbufType *dst = dstbuf->data; const PpuZbufType *src = layerbuf->data; const int left_extra = ppu->extraLeftCur; @@ -1840,15 +2032,28 @@ 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 (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; + 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; + int di = x + kPpuLineBorder, si = sx + kPpuLineBorder; if (src[si] > dst[di]) dst[di] = src[si]; } @@ -1886,7 +2091,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]; } @@ -1897,18 +2102,18 @@ 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; 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]; } } @@ -1922,8 +2127,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) @@ -1953,10 +2160,12 @@ 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, + ppu->wsWorldRepeat[world_band][layer]); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { @@ -1980,8 +2189,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) @@ -2009,10 +2220,12 @@ 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, + ppu->wsWorldRepeat[world_band][layer]); } else if (stretch_band) { PpuMergeStretchedBackground(ppu, dstbuf, &layerbuf); } else { @@ -2048,8 +2261,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]; @@ -2112,8 +2325,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; @@ -2168,16 +2381,16 @@ 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)) 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 { @@ -2237,8 +2450,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] + @@ -2246,7 +2459,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 @@ -2287,7 +2500,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]) @@ -2403,6 +2616,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; @@ -2457,17 +2672,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] = { @@ -2479,14 +2694,16 @@ 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; + 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); @@ -2606,7 +2823,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; } } @@ -2680,7 +2897,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) { @@ -2724,23 +2941,49 @@ 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 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) { @@ -2890,6 +3133,12 @@ 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; + 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 @@ -2901,23 +3150,26 @@ 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) - ? ppu->extraLeftRight + ? ppu->extraFrameLeft : ppu->extraLeftCur; if (PpuWidescreenOamLeftHintAllows(ppu, index, x, spriteSize, 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; } } } @@ -2929,14 +3181,15 @@ 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 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->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]; @@ -2950,20 +3203,31 @@ 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); 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); + 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. * @@ -3034,12 +3298,12 @@ 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( - 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; } @@ -3500,6 +3764,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 1fd58533..6ada413e 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 @@ -242,6 +247,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 @@ -285,11 +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][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][5]; // Piecewise horizontal remaps for the elastic anchor bands (see // PpuSetWidescreenLayerElasticBand). nseg == 0 or y1 <= y0 = slot off. uint8_t wsElasticY0[kPpuWsElasticBands][4]; @@ -325,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. */ @@ -504,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 @@ -513,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; @@ -706,6 +731,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 + 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" +// would under- or over-fill as soon as a game opts in. void PpuSetWidescreenLineEnhancer(Ppu *ppu, PpuWidescreenLineEnhancer *enhancer, void *context); @@ -740,7 +771,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); @@ -762,6 +793,53 @@ 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 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. +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) @@ -793,7 +871,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); @@ -821,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 @@ -929,6 +1013,34 @@ 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 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, + 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); + +// 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/runner/src/snes/ppu_legacy.c b/runner/src/snes/ppu_legacy.c index 3959f52d..da490177 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; @@ -186,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) { @@ -202,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/runner/src/snes/ws_shadow.c b/runner/src/snes/ws_shadow.c index 2e115529..bf6cea5d 100644 --- a/runner/src/snes/ws_shadow.c +++ b/runner/src/snes/ws_shadow.c @@ -21,12 +21,18 @@ 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; + bool providerNativeOverrides; + WsShadowVramMarginFilter vramMarginFilter; + void *vramMarginContext; uint16_t mapBaseWord; uint16_t *entries; uint8_t *valid; @@ -174,6 +180,11 @@ 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->providerNativeOverrides = false; layer->haveLastOrigin = false; layer->haveRetainMapBase = false; layer->havePrevLive = false; @@ -216,14 +227,49 @@ 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; + layer->providerNativeOverrides = false; + 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, 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 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; + 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) { if (layerIndex < 0 || layerIndex >= kLayers) return; @@ -961,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) @@ -1285,8 +1333,13 @@ 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->vramMarginFilter) + return native ? realTile : layer->vramMarginFilter( + layer->vramMarginContext, mapWordAdr, realTile); 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 — @@ -1307,10 +1360,41 @@ 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 (native && !layer->providerNativeOverrides) return realTile; + 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 >= map_width / 2) dx -= map_width; + if (dy >= 512) dy -= 1024; + worldX += dx; + worldY = (int32_t)layer->worldY + dy; + if (worldX >= 0 && worldY >= 0) { + WsShadowSource source = layer->provider(layer->providerContext, + (uint32_t)worldX >> shift, + (uint32_t)worldY >> shift, &exactEntry); + if (source == kWsShadowOverride || + (source == kWsShadowMarginOverride && !native)) 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; - 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 @@ -1363,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 5b44a4db..e5153aff 100644 --- a/runner/src/snes/ws_shadow.h +++ b/runner/src/snes/ws_shadow.h @@ -5,12 +5,14 @@ #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, - 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); @@ -20,6 +22,36 @@ 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); +/* 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 + * 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. MarginOverride wins over uploads only + * outside the native region; Exact never replaces native VRAM. */ +typedef enum WsShadowSource { + 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. */ +typedef WsShadowSource (*WsShadowWorldProvider)(void *context, uint32_t worldTileX, + uint32_t worldTileY, uint16_t *entry); +void WsShadowSetWorldProvider(int layer, WsShadowWorldProvider provider, + 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/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/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; } diff --git a/tests/ppu/mmx23_stream_test.c b/tests/ppu/mmx23_stream_test.c new file mode 100644 index 00000000..9bf8f3f9 --- /dev/null +++ b/tests/ppu/mmx23_stream_test.c @@ -0,0 +1,132 @@ +/* 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 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; + 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)); + } + 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,false); + 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/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 08710268..4aaf4eba 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[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[kPpuLineBorder+100], + ppu->bgBuffers[0].data[kPpuLineBorder-10], + ppu->bgBuffers[0].data[kPpuLineBorder+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/ppu_sprite_limit_test.c b/tests/ppu/ppu_sprite_limit_test.c index b1653506..27a3ff30 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), @@ -805,6 +805,139 @@ 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++) { + 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"); + } + + + /* 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"); diff --git a/tests/ppu/ppu_view_anchor_test.c b/tests/ppu/ppu_view_anchor_test.c new file mode 100644 index 00000000..8d732abf --- /dev/null +++ b/tests/ppu/ppu_view_anchor_test.c @@ -0,0 +1,580 @@ +/* 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_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); + 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] = scroll; + ppu_runLine(ppu, 0); + 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++) + 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); +} + +/* 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; + + 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"); + + check_ultrawide(ppu, ref_hud); + + 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/ppu_world_mirror_test.c b/tests/ppu/ppu_world_mirror_test.c index 2863ae8e..2447bc17 100644 --- a/tests/ppu/ppu_world_mirror_test.c +++ b/tests/ppu/ppu_world_mirror_test.c @@ -219,6 +219,220 @@ 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_windows(Ppu *ppu, uint32_t *out, int layer, + bool big, bool mosaic, int reference_scroll, + 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); + 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 (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; + 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 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, 512}; + 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, 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, + 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"); + } + 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"); + } + } + } + } +} + +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; @@ -302,6 +516,8 @@ int main(void) { "slot 1 is independently addressable"); } + 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; 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/ppu/test_world_provider.py b/tests/ppu/test_world_provider.py new file mode 100644 index 00000000..1f854636 --- /dev/null +++ b/tests/ppu/test_world_provider.py @@ -0,0 +1,30 @@ +"""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] + +@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') + 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/'+source, + 'runner/src/snes/ws_shadow.c', '-o', str(exe)], + cwd=ROOT, check=True) + subprocess.run([str(exe)], check=True) + +if __name__ == '__main__': + 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 new file mode 100644 index 00000000..bc84c4ce --- /dev/null +++ b/tests/ppu/ws_world_provider_test.c @@ -0,0 +1,133 @@ +/* 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" +#include "snes/mmx_door.h" + +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); + ++calls; last_x=x; last_y=y; + *entry=(uint16_t)(0x2000 | ((x + y * 3) & 1023)); + 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,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); + 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); + (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); + 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. */ + 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=kWsShadowMarginOverride; + assert(WsShadowTile(0,100,0x200,0,0x5000,0x1234)==0x1234); + 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))); + /* 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; +} 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 \ 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) {