widescreen: MMX6 2D true-FOV — full_2d opt-in + [widescreen.bg2d] BG widen

Pure-2D sprite games (Mega Man X6) render a true 4:3 field of view with no
overscan to reveal, so 16:9 requires making the engine draw more. Two parts:

- [widescreen] full_2d opts a pure-2D game into the native-wide present path
  (it never emits the 3D sprite-tag hook the gameplay detector keys on, so
  every in-game frame was misclassified as a 4:3 menu and pillarboxed).

- [widescreen.bg2d] is a gen-time hook on the per-layer background tile
  renderer (MMX6 FUN_800270d0) at its column-count / start-tile-column /
  start-screen-x instructions, routed through gpu.c psx_ws_mmx6_bg_{cols,
  startcol,startx}. It draws LEFT=ceil(nw_offset/16) extra 16px columns on
  BOTH sides so the loop fills the 16:9 reveal margins with real adjacent
  stage, existing columns staying pixel-aligned with objects. Identity at
  4:3 and in the engine's own 512 hi-res mode (so the title is untouched).

Tooling: wide_full debug command + sw_wide_dump_full dump the entire
native-wide compositor surface (both vertical-double-buffer y-bands and both
reveal margins) for diagnosis.

Known follow-ups: object screen-X cull widen (actors in the margins);
top/bottom flicker + stale-margin refresh on the vertical double-buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-20 11:57:32 -07:00
parent 226298ae14
commit a8f37c1f76
10 changed files with 236 additions and 9 deletions
+10
View File
@@ -90,6 +90,16 @@ struct CodeGenConfig {
// FOV (identity at 4:3). Overlay-resident; see config_loader.h. Empty=default.
std::set<uint32_t> ws_backdrop_x_sites;
// Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). Three
// instruction addresses in a 2D game's per-layer BG renderer (MMX6's
// FUN_800270d0): the column-count load, the start tile-column mask, and the
// start screen-x shift, rewritten through the gpu.c psx_ws_mmx6_bg_* helpers
// so the loop draws the 16:9 reveal columns on both sides (identity at 4:3 /
// 512 hi-res). 0 = unset. Main-EXE addresses; verified by opcode at gen time.
uint32_t ws_bg2d_count_site = 0;
uint32_t ws_bg2d_startcol_site = 0;
uint32_t ws_bg2d_startx_site = 0;
// Persistent game-option init-store hooks ([persist_options] in
// game_options.toml). Each entry is the PC of a boot-init sb/sh that writes a
// config global's DEFAULT value; the store is rewritten to route the value
+45
View File
@@ -821,6 +821,48 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
// overlay variant: addr is different code here — fall through to vanilla.
}
// Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). MMX6's
// per-layer BG renderer draws `count` 16px tile columns from a start column /
// start screen-x derived from the camera scroll. Rewrite those three values
// (via the gpu.c psx_ws_mmx6_bg_* helpers — identity at 4:3 / 512 hi-res) so
// the loop draws the 16:9 reveal columns on both sides. Each site's opcode is
// verified; a mismatch is a loud build error (main-EXE addresses).
if (config_.ws_bg2d_count_site && addr == config_.ws_bg2d_count_site) {
if (opcode == 0x09 || opcode == 0x0D) { // addiu / ori (li rt,imm)
uint32_t rt = get_rt(instr);
uint16_t imm = get_imm16_u(instr);
return fmt::format("{} = (uint32_t)psx_ws_mmx6_bg_cols({});{}",
reg_name(rt), (int)imm, comment);
} else if (!config_.overlay_mode) {
fmt::print(stderr, "ERROR: [widescreen.bg2d] count_site 0x{:08X} is not "
"addiu/ori (opcode 0x{:02X})\n", addr, opcode);
std::exit(1);
}
}
if (config_.ws_bg2d_startcol_site && addr == config_.ws_bg2d_startcol_site) {
if (opcode == 0x0C) { // andi rt,rs,imm (start tile-column mask)
uint32_t rs = get_rs(instr), rt = get_rt(instr);
uint16_t imm = get_imm16_u(instr);
return fmt::format("{} = (uint32_t)psx_ws_mmx6_bg_startcol((int)({} & 0x{:X}u));{}",
reg_name(rt), reg_name(rs), imm, comment);
} else if (!config_.overlay_mode) {
fmt::print(stderr, "ERROR: [widescreen.bg2d] startcol_site 0x{:08X} is not "
"andi (opcode 0x{:02X})\n", addr, opcode);
std::exit(1);
}
}
if (config_.ws_bg2d_startx_site && addr == config_.ws_bg2d_startx_site) {
if (opcode == 0x00 && (instr & 0x3F) == 0x03) { // sra rd,rt,sa (start screen-x)
uint32_t rt = get_rt(instr), rd = get_rd(instr), sh = get_shamt(instr);
return fmt::format("{} = (uint32_t)psx_ws_mmx6_bg_startx((int32_t){} >> {});{}",
reg_name(rd), reg_name(rt), sh, comment);
} else if (!config_.overlay_mode) {
fmt::print(stderr, "ERROR: [widescreen.bg2d] startx_site 0x{:08X} is not "
"sra (instr 0x{:08X})\n", addr, instr);
std::exit(1);
}
}
// Persistent game-option init store ([persist_options] in game_options.toml).
// The site is the boot-init sb/sh that writes a config global's DEFAULT value
// (Tomba MESSAGE / SOUND / VIBRATION / ADJUST SCREEN). Route the stored value
@@ -2018,6 +2060,9 @@ std::string CodeGenerator::generate_file(
ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n";
ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n";
ss << "extern int psx_ws_backdrop_x(int x); /* widescreen backdrop screenX squash (gpu.c) */\n";
ss << "extern int psx_ws_mmx6_bg_cols(int base); /* ws 2D bg tile-loop widen: col count (gpu.c) */\n";
ss << "extern int psx_ws_mmx6_bg_startcol(int col); /* ws 2D bg tile-loop widen: start tile col (gpu.c) */\n";
ss << "extern int psx_ws_mmx6_bg_startx(int x); /* ws 2D bg tile-loop widen: start screen-x (gpu.c) */\n";
ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n";
ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n";
ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n\n";
+25
View File
@@ -572,6 +572,7 @@ GameConfig load_game_config(const fs::path& config_path_in) {
std::vector<uint32_t> ws_sprite_tag_funcs;
uint32_t ws_sprite_anchor_addr = 0;
bool ws_hud_sprt_squash = false;
bool ws_full_2d = false;
if (cfg.contains("widescreen")) {
const toml::value& ws = toml::find(cfg, "widescreen");
if (ws.contains("sprite_tag_funcs")) {
@@ -591,6 +592,8 @@ GameConfig load_game_config(const fs::path& config_path_in) {
config_path.string()));
if (ws.contains("hud_sprt_squash"))
ws_hud_sprt_squash = toml::find<bool>(ws, "hud_sprt_squash");
if (ws.contains("full_2d"))
ws_full_2d = toml::find<bool>(ws, "full_2d");
}
// Optional [widescreen.cull] block — world-space draw-cull widening.
@@ -616,6 +619,24 @@ GameConfig load_game_config(const fs::path& config_path_in) {
}
}
// Optional [widescreen.bg2d] block — pure-2D background tile-loop widen.
uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0, ws_bg2d_startx_site = 0;
if (cfg.contains("widescreen")) {
const toml::value& ws = toml::find(cfg, "widescreen");
if (ws.contains("bg2d")) {
const toml::value& bg = toml::find(ws, "bg2d");
auto load1 = [&](const char* key) -> uint32_t {
return bg.contains(key)
? parse_hex(toml::find<std::string>(bg, key),
fmt::format("widescreen.bg2d.{}", key))
: 0u;
};
ws_bg2d_count_site = load1("count_site");
ws_bg2d_startcol_site = load1("startcol_site");
ws_bg2d_startx_site = load1("startx_site");
}
}
// Optional [widescreen.backdrop] block — parallax 2D backdrop screenX squash
// (x_sites) + far-backdrop GTE un-squash (unsquash_funcs).
std::vector<uint32_t> ws_backdrop_x_sites;
@@ -665,6 +686,10 @@ GameConfig load_game_config(const fs::path& config_path_in) {
/*ws_backdrop_unsquash_funcs*/ ws_backdrop_unsquash_funcs,
/*ws_auto_screen_x_cull*/ ws_auto_screen_x_cull,
/*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload,
/*ws_full_2d*/ ws_full_2d,
/*ws_bg2d_count_site*/ ws_bg2d_count_site,
/*ws_bg2d_startcol_site*/ ws_bg2d_startcol_site,
/*ws_bg2d_startx_site*/ ws_bg2d_startx_site,
};
}
+24
View File
@@ -358,6 +358,30 @@ struct GameConfig {
// are overlay-resident, so the overlay compile must see this (--ws-config).
// 0 at 4:3 (native-wide inactive) => byte-identical. Off by default; regen.
bool ws_auto_backdrop_preload = false;
// [widescreen] full_2d — pure-2D sprite game (e.g. Mega Man X6) that never
// emits the sprite_tag hook the 3D detector relies on. When true, every
// in-game frame is treated as "gameplay" so native-wide engages and the 2D
// scene is presented widescreen (the framework's normal full-2D screens
// pillarbox 4:3, which is wrong for a game that IS 2D end-to-end). Runtime-
// only (read at startup; no codegen impact) — no regen required. The wider
// field of view itself is supplied by [widescreen.cull]/engine hooks; this
// flag only opts the title into the 2D widescreen present path. Off by
// default; the env var PSX_WS_FORCE_2D=1 forces it on for testing.
bool ws_full_2d = false;
// [widescreen.bg2d] — pure-2D background tile-loop widen (e.g. MMX6's
// FUN_800270d0). Three instruction addresses in the per-layer BG renderer
// whose column count and loop start are rewritten so the loop draws the
// 16:9 reveal columns on both sides of the 320 view (see gpu.c
// psx_ws_mmx6_bg_* helpers — identity at 4:3 / 512 hi-res mode). Regen-class.
// count_site: the `li rt,21` column-count load (addiu/ori).
// startcol_site: the `andi rt,rs,0x3f` start tile-column mask.
// startx_site: the `sra rd,rt,sa` start screen-x.
// 0 = unset (feature off). Verified by opcode at gen time.
uint32_t ws_bg2d_count_site = 0;
uint32_t ws_bg2d_startcol_site = 0;
uint32_t ws_bg2d_startx_site = 0;
};
// UserSettings — the launcher-written, user-editable override layer.
+11
View File
@@ -99,6 +99,8 @@ int main(int argc, char** argv) {
bool ws_auto_screen_x_cull = false; // [widescreen.cull] auto_screen_x
std::set<uint32_t> persist_init_sites; // [persist_options] init-store hooks (game_options.toml)
bool ws_auto_backdrop_preload = false; // [widescreen.cull] auto_backdrop
uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0,
ws_bg2d_startx_site = 0; // [widescreen.bg2d]
std::filesystem::path out_dir = "generated";
if (!config_path.empty()) {
@@ -116,6 +118,9 @@ int main(int argc, char** argv) {
ws_backdrop_unsquash.insert(cfg.ws_backdrop_unsquash_funcs.begin(), cfg.ws_backdrop_unsquash_funcs.end());
ws_auto_screen_x_cull = ws_auto_screen_x_cull || cfg.ws_auto_screen_x_cull;
ws_auto_backdrop_preload = ws_auto_backdrop_preload || cfg.ws_auto_backdrop_preload;
if (cfg.ws_bg2d_count_site) ws_bg2d_count_site = cfg.ws_bg2d_count_site;
if (cfg.ws_bg2d_startcol_site) ws_bg2d_startcol_site = cfg.ws_bg2d_startcol_site;
if (cfg.ws_bg2d_startx_site) ws_bg2d_startx_site = cfg.ws_bg2d_startx_site;
// [persist_options] init-store hook sites live in a dedicated
// game_options.toml next to game.toml (the game's own native OPTION
// settings, kept separate from game.toml/settings.toml). Best-effort:
@@ -685,6 +690,12 @@ int main(int argc, char** argv) {
codegen_config.ws_auto_screen_x_cull = ws_auto_screen_x_cull;
codegen_config.persist_init_store_sites = persist_init_sites;
codegen_config.ws_auto_backdrop_preload = ws_auto_backdrop_preload;
codegen_config.ws_bg2d_count_site = ws_bg2d_count_site;
codegen_config.ws_bg2d_startcol_site = ws_bg2d_startcol_site;
codegen_config.ws_bg2d_startx_site = ws_bg2d_startx_site;
if (ws_bg2d_count_site || ws_bg2d_startcol_site || ws_bg2d_startx_site)
fmt::print(" ws_bg2d 2D-background widen = ON (count=0x{:08X} startcol=0x{:08X} startx=0x{:08X})\n",
ws_bg2d_count_site, ws_bg2d_startcol_site, ws_bg2d_startx_site);
if (ws_auto_screen_x_cull)
fmt::print(" ws_auto_screen_x_cull = ON (render-funnel FOV widening)\n");
if (!persist_init_sites.empty())
+9
View File
@@ -95,6 +95,15 @@ void gpu_set_screen_kind(int kind);
* (render the wider FOV into a wider frame, present 1:1; GTE not squashed). */
void gpu_ws_configure(int aspect_num, int aspect_den,
uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode);
/* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present
* path (treat every in-game frame as gameplay, since it never tags 3D prims). */
void gpu_ws_set_full_2d(int on);
/* [widescreen.bg2d] MMX6 background tile-loop widen — hooked at the renderer's
* column-count / start-tile-col / start-screen-x instructions. Identity at 4:3
* and in the engine's 512 hi-res mode. */
int psx_ws_mmx6_bg_cols(int base);
int psx_ws_mmx6_bg_startcol(int col);
int psx_ws_mmx6_bg_startx(int x);
struct CPUState;
void psx_ws_sprite_tag(struct CPUState* cpu);
+42
View File
@@ -6204,6 +6204,47 @@ static void handle_wide_shot(int id, const char *json)
id, path, W, H);
}
/* wide_full: dump the ENTIRE active wide compositor surface (both double-buffer
* y-bands, full width incl. both reveal margins) to a PNG. Unlike wide_shot
* (which crops to the displayed band), this shows where the over-draw actually
* lands across the whole surface. SW backend only (diagnostic). base_x defaults
* to 0 (the common vertical-double-buffer origin). */
static void handle_wide_full(int id, const char *json)
{
extern int sw_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x);
int base_x = json_get_int(json, "base_x", 0);
char path[512];
if (!json_get_str(json, "path", path, sizeof(path)))
strncpy(path, "psx_wide_full.png", sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
int cap = 1024 * 1024 * 4; /* up to 4 Mpix (e.g. 426*512*S^2) */
uint32_t *buf = (uint32_t *)malloc((size_t)cap * sizeof(uint32_t));
if (!buf) { send_err(id, "alloc failed"); return; }
int W = 0, H = 0;
int n = sw_wide_dump_full(buf, cap, &W, &H, base_x);
if (n <= 0) { free(buf); send_err(id, "no wide surface (SW backend? native-wide engaged?)"); return; }
uint8_t *rgb = (uint8_t *)malloc((size_t)W * H * 3);
if (!rgb) { free(buf); send_err(id, "alloc failed"); return; }
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
uint32_t px = buf[(size_t)y * W + x];
uint8_t *p = rgb + ((size_t)y * W + x) * 3;
p[0] = (uint8_t)((px >> 16) & 0xFF);
p[1] = (uint8_t)((px >> 8) & 0xFF);
p[2] = (uint8_t)(px & 0xFF);
}
free(buf);
FILE *f = fopen(path, "wb");
if (!f) { free(rgb); send_err(id, "cannot open file"); return; }
int ok = png_write_rgb(f, rgb, (uint32_t)W, (uint32_t)H);
free(rgb); fclose(f);
if (!ok) { send_err(id, "png encode failed"); return; }
send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"width\":%d,\"height\":%d}",
id, path, W, H);
}
static void handle_vram_peek(int id, const char *json)
{
int x = json_get_int(json, "x", 0);
@@ -9254,6 +9295,7 @@ static const CmdEntry s_commands[] = {
{ "get_snapshots", handle_get_snapshots },
{ "screenshot", handle_screenshot_file },
{ "screenshot_file", handle_screenshot_file }, /* alias */
{ "wide_full", handle_wide_full },
{ "wide_shot", handle_wide_shot },
{ "gpu_opcodes", handle_gpu_opcodes },
{ "gpu_ring_stats", handle_gpu_ring_stats },
+47 -9
View File
@@ -103,16 +103,19 @@ static int32_t ws_disp_w(void);
* within the last couple of frames => the actor render funnel is running =>
* gameplay. A menu/title/save screen draws no tagged prims. The 2-frame window
* makes this frame-stable regardless of per-prim ordering within a frame. */
/* Full-2D widescreen ([widescreen] full_2d). A pure 2D sprite game (e.g. MMX6)
* never emits the sprite-tag hook the 3D gameplay detector keys on, so every
* in-game frame would be misclassified as a "full-2D menu" and pillarboxed 4:3.
* When this is set, treat every in-game frame as gameplay so native-wide engages
* and the 2D scene presents widescreen. Set once from game.toml at widescreen-
* engage (gpu_ws_set_full_2d); PSX_WS_FORCE_2D=1 forces it on for testing. */
static int ws_full_2d = 0;
void gpu_ws_set_full_2d(int on) { ws_full_2d = on ? 1 : 0; }
static int ws_game_mode(void) {
/* EXPERIMENT (PSX_WS_FORCE_2D=1): force "gameplay" so native-wide engages on
* an all-2D sprite game (MMX6) that never emits the sprite-tag hook. This is
* the 2D "overscan reveal" path — the game over-draws past the 320 viewport
* (clipped by the draw area), and native-wide's draw_offset shift + draw-area
* widen + wide-mirror + widened present reveal it. Env-gated so it's regen-
* free and off for tagged 3D games. */
static int force = -1;
if (force < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); force = (e && e[0] == '1') ? 1 : 0; }
if (force) return 1;
static int env = -1;
if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; }
if (ws_full_2d || env) return 1;
return (uint32_t)s_frame_count - ws_last_tag_stamp <= 2;
}
@@ -191,6 +194,41 @@ int psx_ws_x_margin(void) {
return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum;
}
/* ---- MMX6 2D background tile-loop widen ([widescreen.bg2d]) ---------------
* Mega Man X6 is a pure-2D sprite engine that renders only a 4:3 (320px) field
* of view — there is no overscan to "reveal." Its per-layer background renderer
* (FUN_800270d0) draws `count` 16px tile columns × 16 rows from a start tile
* column / start screen-x derived from the camera scroll. To produce a TRUE
* wider FOV we widen that loop so it draws extra columns on BOTH sides of the
* 320 view, filling the 16:9 reveal margins with real adjacent stage. The three
* helpers below are hooked at the renderer's count / start-col / start-x
* instructions by the recompiler (gen-time). They are IDENTITY unless native-
* wide is engaged on a ~320 screen, so 4:3 (and the engine's own 512 hi-res
* mode, which already draws 33 columns) stay byte-identical.
*
* LEFT = per-side reveal in whole 16px tile columns = ceil(nw_offset / 16). The
* loop start moves LEFT columns earlier (tile col -LEFT in the 64-col ring,
* screen-x -LEFT*16) and the count grows by 2*LEFT, so existing columns keep
* their screen positions (stay aligned with objects/player) while new columns
* are prepended left and appended right. The 64-column tile ring (mask 0x3f) is
* far wider than the visible window, so the prepended columns fetch the correct
* already-streamed adjacent tiles. */
static int ws_mmx6_left_cols(void) {
if (!ws_native_wide_active()) return 0;
/* Only the ~320 gameplay mode; the engine's 512 hi-res mode (title) draws
* its own 33 columns and centres itself — never double-shift it. */
if (ws_disp_w() > 384) return 0;
int off = ws_nw_offset(); /* per-side reveal in screen px */
if (off <= 0) return 0;
return (off + 15) / 16; /* ceil to whole tile columns */
}
/* Column count: base (21) + the columns revealed on both sides. */
int psx_ws_mmx6_bg_cols(int base) { return base + 2 * ws_mmx6_left_cols(); }
/* Start tile column: LEFT earlier, wrapped in the 64-column ring (mask 0x3f). */
int psx_ws_mmx6_bg_startcol(int col) { return (col - ws_mmx6_left_cols()) & 0x3f; }
/* Start screen-x: LEFT*16 further left (more negative). */
int psx_ws_mmx6_bg_startx(int x) { return x - ws_mmx6_left_cols() * 16; }
/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x),
* called identically by the gcc emit, the sljit JIT, and the interpreter so every
* overlay execution path widens the same way. sx = the GTE screen-X as loaded by
+19
View File
@@ -1610,3 +1610,22 @@ int sw_render_wide_display(uint32_t *out_pixels, int out_pitch, int base_x,
}
int sw_wide_width(void) { return g_wide_w; }
/* Diagnostic: dump the ENTIRE active wide surface for base_x (full g_wide_w*scale
* × VRAM_HEIGHT*scale — BOTH double-buffer y-bands, all margins) to ARGB. Lets a
* probe see exactly where the over-draw lands, independent of the per-frame band
* the present selects. *ow/*oh receive the surface dims. Returns pixels written,
* or 0 if no surface for base_x / it won't fit in cap_pixels. */
int sw_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x) {
uint16_t *surf = NULL;
for (int i = 0; i < WIDE_MAX_SURF; i++)
if (g_wide_surf[i] && g_wide_base[i] == base_x) { surf = g_wide_surf[i]; break; }
if (!surf || g_wide_w <= 0) return 0;
int s = g_scale;
int W = g_wide_w * s, H = VRAM_HEIGHT * s;
if ((long long)W * H > cap_pixels) return 0;
for (int i = 0; i < W * H; i++) out[i] = rgb555_to_argb(surf[i]);
if (ow) *ow = W;
if (oh) *oh = H;
return W * H;
}
+4
View File
@@ -1915,6 +1915,10 @@ int main(int argc, char** argv) {
g_fmv_skip_end_total = gc.runtime.video_fmv_skip_end_total;
g_ws_anchor_addr = gc.ws_sprite_anchor_addr;
g_ws_hud_sprt = gc.ws_hud_sprt_squash;
/* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the
* widescreen present path. Applied to the GPU layer up front so the
* ws engage at game entry classifies every frame as gameplay. */
gpu_ws_set_full_2d(gc.ws_full_2d ? 1 : 0);
/* Register the [widescreen.backdrop] store PCs so the dirty-RAM
* interpreter applies the backdrop screenX squash on the interp
* path (overlay backdrop handlers run interpreted when no cache