diff --git a/docs/upstream-patches.md b/docs/upstream-patches.md index d58d8115..3dd7b3f9 100644 --- a/docs/upstream-patches.md +++ b/docs/upstream-patches.md @@ -121,6 +121,40 @@ active. Exclusive `fullscreen_width/height` modes ignore it; if both `fullscreen=1` and `borderless=1` are set, `fullscreen` wins. The stale commented-out `-borderless` command-line snippet was removed. +## Video: HD presentation filter (scale2x/scale3x) + +New `[Video]` SETUP.INI keys, read once at boot in `VIDEO_LoadPrefs` +(`src/i_video.cpp`): `upscaler` (0 = off, 2/3/4 = scale2x/scale3x/ +2x scale2x, default 3) and `scanlines` (0/1, default 0). The filter is a +presentation-level CPU pass over the final RGBA frame before it enters the +streaming texture — no engine logic or asset changes; the 4:3 playfield is +kept. Kernels are the AdvMAME scale2x/scale3x rules by Andrea Mazzoleni, +reimplemented in-repo (`src/i_upscale.cpp`, GPLv2+-compatible; nothing +vendored). Filtered frames render directly from the factor-sized streaming +texture and bypass the nearest-integer intermediate (`texture_upscaled`), +which only sharpens sub-integer fits of a 1x source. With `upscaler=0` the +original four-stage pixel-perfect path is byte-for-byte unchanged. The +scanline overlay darkens every third logical row (320x240 space) after the +final copy, so it stays geometry-safe under every window mode. Like +`Video/borderless`, these are hand-edited INI settings; `raptorsetup` is +not extended. + + +## Effects: machine-gun casings eject into world space + +Upstream's forward-gun muzzle effect (`A_PLAYER_SHOOT`, the GUNSTR +diagonal streak art) is a player-relative anim: the streak is glued to +the wingtip and flickers in place for its 4-step life, which at the +decoupled presentation rate reads as shells vibrating at the gun rather +than being ejected. This fork registers the same art once more as +`A_PLAYER_EJECTA` (GROUND layer, `src/anims.cpp`) and spawns that at each +wingtip's world position (`SHOTS_PlayerShoot` case `S_FORWARD_GUNS`) via +`ANIMS_StartGAnim`. The casings therefore stay behind as the plane moves +on and drift down with the map scroll (1 px per simulation step, ~23 px/s) +for their 172 ms life. Sim-side timing is untouched: spawn cadence +(shoot_rate 2), anim rate, RNG consumption and demo playback are +identical to upstream; only the anim's reference frame changes. + ## Input: no cursor snapping Dialog/menu code no longer warps the mouse cursor onto the default @@ -132,6 +166,23 @@ selection still highlights fields without moving the pointer. The mission-start `PTR_SetPos(playerx, playery)` in `Do_Game` is kept — it seeds mouse ship control, not menu focus. +## UI: in-game options menu with runtime video settings + +The Options dialog now carries the HD-remaster video settings that were +previously boot-only INI keys. `ui/OPTS_SWD` was re-authored (same catalog +handle `0x1003f`; built by `tools/swdbuild.py` from +`tools/opts_swd_spec.py`) from 13 to 23 fields, adding FILTER, SCANLINES, +RESOLUTION, FULLSCREEN and ASPECT rows with selection icons beside the +existing Music/FX/Detail rows, and new `OPTS_*` ids in `src/winids.h`. +`src/i_video.cpp` gained runtime setters (`I_SetVideoFilter/Scanlines/ +VideoResolution/Fullscreen/AspectCorrect`) that only touch presentation +resources — the factor-sized surface/texture pair was extracted from +`SetVideoMode` into `RebuildStreamTextures` so the filter can change +without re-creating the 8-bit screenbuffer. Changes live-apply from the +dialog and persist to `[Video]` SETUP.INI keys (`upscaler`, `scanlines`, +`fullscreen`, `aspect_ratio_correct`) on EXIT, and the dialog is also +reachable in-mission via the O key (`Do_Game`, bracketed like F1 help). + ## Deliberately unchanged - Game logic, mission data formats, audio synthesis — untouched. diff --git a/raptor/CMakeLists.txt b/raptor/CMakeLists.txt index 965350a1..2cd41d6c 100644 --- a/raptor/CMakeLists.txt +++ b/raptor/CMakeLists.txt @@ -35,6 +35,7 @@ set(SOURCE_FILES src/glbapi.cpp src/i_video.cpp src/musapi.cpp + src/i_upscale.cpp src/movie_a.cpp src/ptrapi_a.cpp src/swdapi.cpp diff --git a/raptor/src/anims.cpp b/raptor/src/anims.cpp index 4edc1d9c..9b6665a6 100644 --- a/raptor/src/anims.cpp +++ b/raptor/src/anims.cpp @@ -183,6 +183,9 @@ ANIMS_Init( // SUPER SHIELD ANIMS_Register(FILE149_SHIPGLOW_BLK, 4, HIGH_AIR, 1, 1, A_NORM); + + // MACHINE-GUN EJECTA (world-space casing streaks, GUNSTR art) + ANIMS_Register(FILE1c7_GUNSTR_BLK, 4, GROUND, 0, 0, A_NORM); } /*************************************************************************** diff --git a/raptor/src/anims.h b/raptor/src/anims.h index b10d228d..b184c61d 100644 --- a/raptor/src/anims.h +++ b/raptor/src/anims.h @@ -70,6 +70,11 @@ typedef struct ANIMS_S #define A_ENERGY_GRAB 19 #define A_SUPER_SHIELD 20 +// Machine-gun ejecta: same GUNSTR streak art dropped into world space at +// the wingtips (ground layer, drifts with the map scroll) so casings stay +// behind the plane instead of flicking in place at the gun. +#define A_PLAYER_EJECTA 21 + void ANIMS_Clear(void); ANIMS *ANIMS_Remove(ANIMS *anim); void ANIMS_Init(void); diff --git a/raptor/src/i_upscale.cpp b/raptor/src/i_upscale.cpp new file mode 100644 index 00000000..d9c85b2a --- /dev/null +++ b/raptor/src/i_upscale.cpp @@ -0,0 +1,162 @@ +// +// Copyright(C) 1993-1996 Id Software, Inc. +// Copyright(C) 2005-2014 Simon Howard +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// DESCRIPTION: +// AdvMAME scale2x/scale3x filters by Andrea Mazzoleni, reimplemented +// in-repo (GPLv2+, same license as the project). Each source pixel E +// with its neighbors +// +// A B C +// D E F +// G H I +// +// expands to a 2x2 (scale2x) or 3x3 (scale3x) block of output pixels. +// Factor 4 is scale2x applied twice, the reference composition. +// + +#include + +#include "common.h" +#include "i_upscale.h" + +// Exact per-byte average of two packed RGBA pixels: the common bits plus +// half the differing bits. The mask keeps the halving inside each byte +// lane, so no carry crosses channels and no unpacking is needed. + +static uint32_t pixel_blend(uint32_t x, uint32_t y) +{ + return (x & y) + (((x ^ y) >> 1) & 0x7f7f7f7f); +} + +static void scale2x_pass(const uint32_t *src, int spitch, int w, int h, + uint32_t *dst, int dpitch) +{ + int x, y; + + for (y = 0; y < h; ++y) + { + const uint32_t *row = src + y * spitch; + const uint32_t *above = y > 0 ? row - spitch : row; + const uint32_t *below = y < h - 1 ? row + spitch : row; + uint32_t *out0 = dst + (y * 2) * dpitch; + uint32_t *out1 = dst + (y * 2 + 1) * dpitch; + + for (x = 0; x < w; ++x) + { + // Out-of-bounds neighbors are treated as E (edges of the + // above/below row aliases and of the x clamp do exactly that). + + uint32_t E = row[x]; + uint32_t B = above[x]; + uint32_t H = below[x]; + uint32_t D = x > 0 ? row[x - 1] : E; + uint32_t F = x < w - 1 ? row[x + 1] : E; + uint32_t a, b, c, d; + + a = (D == B && D != H && B != F) ? D : E; + b = (B == F && B != D && F != H) ? B : E; + c = (D == H && D != B && H != F) ? D : E; + d = (H == F && H != D && F != B) ? F : E; + + out0[x * 2] = a; + out0[x * 2 + 1] = b; + out1[x * 2] = c; + out1[x * 2 + 1] = d; + } + } +} + +static void scale3x_pass(const uint32_t *src, int spitch, int w, int h, + uint32_t *dst, int dpitch) +{ + int x, y; + + for (y = 0; y < h; ++y) + { + const uint32_t *row = src + y * spitch; + const uint32_t *above = y > 0 ? row - spitch : row; + const uint32_t *below = y < h - 1 ? row + spitch : row; + uint32_t *out0 = dst + (y * 3) * dpitch; + uint32_t *out1 = dst + (y * 3 + 1) * dpitch; + uint32_t *out2 = dst + (y * 3 + 2) * dpitch; + + for (x = 0; x < w; ++x) + { + uint32_t E = row[x]; + uint32_t B = above[x]; + uint32_t H = below[x]; + uint32_t D = x > 0 ? row[x - 1] : E; + uint32_t F = x < w - 1 ? row[x + 1] : E; + uint32_t a0, a1, a2, a3, a4, a5, a6, a7, a8; + + a4 = E; + a1 = (B == D) ? D : E; + a5 = (B == F) ? F : E; + a7 = (H == F) ? F : E; + a3 = (H == D) ? D : E; + + a0 = (B == D && B != F && D != H) ? D + : ((D == B && D == H && F != H && B != F) ? pixel_blend(D, B) : E); + a2 = (B == F && B != D && F != H) ? F + : ((B == F && H == F && D != H && B != D) ? pixel_blend(B, F) : E); + a6 = (D == H && D != B && H != F) ? D + : ((D == H && H == F && B != F && D != B) ? pixel_blend(D, H) : E); + a8 = (H == F && H != D && F != B) ? F + : ((H == F && F == B && D != B && H != D) ? pixel_blend(H, F) : E); + + out0[x * 3] = a0; + out0[x * 3 + 1] = a1; + out0[x * 3 + 2] = a2; + out1[x * 3] = a3; + out1[x * 3 + 1] = a4; + out1[x * 3 + 2] = a5; + out2[x * 3] = a6; + out2[x * 3 + 1] = a7; + out2[x * 3 + 2] = a8; + } + } +} + +void I_UpscaleFrame(const uint32_t *src, int srcpitch_px, int w, int h, + uint32_t *dst, int dstpitch_px, int factor) +{ + if (factor == 2) + { + scale2x_pass(src, srcpitch_px, w, h, dst, dstpitch_px); + } + else if (factor == 3) + { + scale3x_pass(src, srcpitch_px, w, h, dst, dstpitch_px); + } + else if (factor == 4) + { + // scale2x applied twice (640x400x4 = 1 MB temporary). + + uint32_t *tmp = (uint32_t *) malloc(sizeof(uint32_t) + * w * 2 * h * 2); + + if (tmp == NULL) + { + EXIT_Error("I_UpscaleFrame: memory"); + } + + scale2x_pass(src, srcpitch_px, w, h, tmp, w * 2); + scale2x_pass(tmp, w * 2, w * 2, h * 2, dst, dstpitch_px); + free(tmp); + } + else + { + EXIT_Error("I_UpscaleFrame: bad factor %d", factor); + } +} diff --git a/raptor/src/i_upscale.h b/raptor/src/i_upscale.h new file mode 100644 index 00000000..f84322c5 --- /dev/null +++ b/raptor/src/i_upscale.h @@ -0,0 +1,28 @@ +// +// Copyright(C) 1993-1996 Id Software, Inc. +// Copyright(C) 2005-2014 Simon Howard +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// DESCRIPTION: +// Presentation-level pixel-art upscaler (AdvMAME scale2x/scale3x). +// + +#pragma once + +#include + +// Upscale a w x h 32-bit RGBA frame by factor (2, 3 or 4) into dst, which +// must be w*factor x h*factor pixels. Pitches are in pixels, not bytes; +// the caller converts surface pitches. No SDL dependency, no global state. + +void I_UpscaleFrame(const uint32_t *src, int srcpitch_px, int w, int h, + uint32_t *dst, int dstpitch_px, int factor); diff --git a/raptor/src/i_video.cpp b/raptor/src/i_video.cpp index eb7d617b..148502bc 100644 --- a/raptor/src/i_video.cpp +++ b/raptor/src/i_video.cpp @@ -38,6 +38,7 @@ #include "musapi.h" #include "prefapi.h" #include "joyapi.h" +#include "i_upscale.h" // These are (1) the window (or the full screen) that our game is rendered to // and (2) the renderer that scales the texture (see below) into this window. @@ -61,6 +62,12 @@ static SDL_Surface *argbbuffer = NULL; static SDL_Texture *texture = NULL; static SDL_Texture *texture_upscaled = NULL; +// HD remaster: when the presentation upscaler is active, the filtered +// factor-sized RGBA frame lands here before going into the streaming +// texture. + +static SDL_Surface *argbbuffer_scaled = NULL; + static SDL_Rect blit_rect = { 0, 0, @@ -117,6 +124,13 @@ int fullscreen_width = 0, fullscreen_height = 0; static int max_scaling_buffer_pixels = 16000000; +// HD remaster: presentation-level pixel-art upscaler (0 = off, else 2/3/4) +// and scanline overlay (0 = off, 1 = on). Read from [Video] upscaler / +// [Video] scanlines in SETUP.INI at boot only; changing them needs a restart. + +static int upscale_factor = 3; +static int scanlines = 0; + // Run in full screen mode? (int type for config code) // int fullscreen = true; @@ -218,6 +232,14 @@ void VIDEO_LoadPrefs(void) aspect_ratio_correct = INI_GetPreferenceLong("Video", "aspect_ratio_correct", 1); txt_fullscreen = INI_GetPreferenceLong("Video", "txt_fullscreen", 0); borderless = INI_GetPreferenceLong("Video", "borderless", 0); + upscale_factor = INI_GetPreferenceLong("Video", "upscaler", 3); + scanlines = INI_GetPreferenceLong("Video", "scanlines", 0); + + if (upscale_factor != 0 && upscale_factor != 2 && upscale_factor != 3 + && upscale_factor != 4) + { + upscale_factor = 3; + } } static bool MouseShouldBeGrabbed() @@ -854,24 +876,64 @@ void I_FinishUpdate (void) SDL_LowerBlit(screenbuffer, &blit_rect, argbbuffer, &blit_rect); - // Update the intermediate texture with the contents of the RGBA buffer. + if (upscale_factor > 1) + { + // HD remaster path: filter the RGBA frame into the factor-sized + // surface and stream it directly to screen. The source is already + // integer-scaled by the filter, so the nearest-integer intermediate + // (which exists to sharpen sub-integer fits of a 1x source) is not + // used here. - SDL_UpdateTexture(texture, NULL, argbbuffer->pixels, argbbuffer->pitch); + I_UpscaleFrame((uint32_t *) argbbuffer->pixels, + argbbuffer->pitch / 4, + SCREENWIDTH, SCREENHEIGHT, + (uint32_t *) argbbuffer_scaled->pixels, + argbbuffer_scaled->pitch / 4, + upscale_factor); - // Make sure the pillarboxes are kept clear each frame. + SDL_UpdateTexture(texture, NULL, argbbuffer_scaled->pixels, + argbbuffer_scaled->pitch); - SDL_RenderClear(renderer); + SDL_RenderClear(renderer); + SDL_SetRenderTarget(renderer, NULL); + SDL_RenderCopy(renderer, texture, NULL, NULL); + } + else + { + // Update the intermediate texture with the contents of the RGBA buffer. - // Render this intermediate texture into the upscaled texture - // using "nearest" integer scaling. + SDL_UpdateTexture(texture, NULL, argbbuffer->pixels, argbbuffer->pitch); - SDL_SetRenderTarget(renderer, texture_upscaled); - SDL_RenderCopy(renderer, texture, NULL, NULL); + // Make sure the pillarboxes are kept clear each frame. - // Finally, render this upscaled texture to screen using linear scaling. + SDL_RenderClear(renderer); - SDL_SetRenderTarget(renderer, NULL); - SDL_RenderCopy(renderer, texture_upscaled, NULL, NULL); + // Render this intermediate texture into the upscaled texture + // using "nearest" integer scaling. + + SDL_SetRenderTarget(renderer, texture_upscaled); + SDL_RenderCopy(renderer, texture, NULL, NULL); + + // Finally, render this upscaled texture to screen using linear scaling. + + SDL_SetRenderTarget(renderer, NULL); + SDL_RenderCopy(renderer, texture_upscaled, NULL, NULL); + } + + if (scanlines) + { + // Scanline overlay: every third logical row darkened. Logical size + // is 320xactualheight, so the lines stay evenly spaced physical rows + // under any window size. + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 70); + + for (int y = 1; y < actualheight; y += 3) + { + SDL_RenderDrawLine(renderer, 0, y, SCREENWIDTH, y); + } + } // Draw! @@ -1242,6 +1304,7 @@ void I_GetWindowPosition(int *x, int *y, int w, int h) } } +static void RebuildStreamTextures(void); static void SetVideoMode(void) { int w, h; @@ -1452,6 +1515,33 @@ static void SetVideoMode(void) SDL_FillRect(argbbuffer, NULL, 0); } + // HD remaster: with the upscaler active, a factor-sized RGBA surface + // feeds the (factor-sized) streaming texture. Freed alongside + // argbbuffer above on every re-init. + + RebuildStreamTextures(); +} + +// Rebuild the factor-sized RGBA surface and the streaming texture that +// carry the frame at the current upscale_factor. Shared by SetVideoMode +// (full re-init) and the runtime filter setter (I_SetVideoFilter). +static void RebuildStreamTextures(void) +{ + if (argbbuffer_scaled != NULL) + { + SDL_FreeSurface(argbbuffer_scaled); + argbbuffer_scaled = NULL; + } + + if (upscale_factor > 1) + { + argbbuffer_scaled = SDL_CreateRGBSurfaceWithFormat(0, + SCREENWIDTH * upscale_factor, + SCREENHEIGHT * upscale_factor, + 32, pixel_format); + SDL_FillRect(argbbuffer_scaled, NULL, 0); + } + if (texture != NULL) { SDL_DestroyTexture(texture); @@ -1470,13 +1560,92 @@ static void SetVideoMode(void) texture = SDL_CreateTexture(renderer, pixel_format, SDL_TEXTUREACCESS_STREAMING, - SCREENWIDTH, SCREENHEIGHT); + upscale_factor > 1 + ? SCREENWIDTH * upscale_factor + : SCREENWIDTH, + upscale_factor > 1 + ? SCREENHEIGHT * upscale_factor + : SCREENHEIGHT); // Initially create the upscaled texture for rendering to screen CreateUpscaledTexture(true); } +//************************************************************************** +// Runtime video option setters (in-game Options dialog) +// +// These only touch presentation resources. Never call SetVideoMode() from +// here: it frees and recreates screenbuffer without re-pointing +// I_VideoBuffer, so the game would keep drawing into freed memory. +//************************************************************************** + +void I_SetVideoFilter(int factor) +{ + if (factor != 0 && factor != 2 && factor != 3 && factor != 4) + return; + + if (factor == upscale_factor) + return; + + upscale_factor = factor; + RebuildStreamTextures(); +} + +void I_SetScanlines(int on) +{ + scanlines = on ? 1 : 0; +} + +void I_SetVideoResolution(int w, int h) +{ + if (fullscreen) + I_SetFullscreen(0); + + window_width = w; + window_height = h; + AdjustWindowSize(); + SDL_SetWindowSize(screen, window_width, window_height); + + // The RESIZED event path in I_FinishUpdate adapts the textures. +} + +void I_SetFullscreen(int on) +{ + fullscreen = on ? 1 : 0; + + SDL_SetWindowFullscreen(screen, + fullscreen ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0); + + if (!fullscreen) + { + AdjustWindowSize(); + SDL_SetWindowSize(screen, window_width, window_height); + } +} + +void I_SetAspectCorrect(int on) +{ + aspect_ratio_correct = on ? 1 : 0; + actualheight = aspect_ratio_correct ? SCREENHEIGHT_4_3 : SCREENHEIGHT; + + SDL_RenderSetLogicalSize(renderer, SCREENWIDTH, actualheight); + AdjustWindowSize(); + SDL_SetWindowSize(screen, window_width, window_height); + CreateUpscaledTexture(false); + palette_to_set = true; +} + +int I_GetVideoFilter(void) +{ + return upscale_factor; +} + +int I_GetScanlines(void) +{ + return scanlines; +} + void I_InitGraphics(uint8_t *pal) { SDL_Event dummy; diff --git a/raptor/src/i_video.h b/raptor/src/i_video.h index 925e976f..90f8cdaf 100644 --- a/raptor/src/i_video.h +++ b/raptor/src/i_video.h @@ -37,6 +37,7 @@ extern int window_width; extern int window_height; extern int fullscreen; extern int txt_fullscreen; +extern int aspect_ratio_correct; extern pixel_t *I_VideoBuffer; @@ -50,6 +51,15 @@ void I_GetMousePos(int *x, int *y); void I_SetMousePos(int x, int y); void I_SetGrabMouseCallback(grabmouse_callback_t func); void VIDEO_LoadPrefs(void); + +// Runtime video option setters (in-game Options dialog) +void I_SetVideoFilter(int factor); // 0/2/3/4, anything else is ignored +void I_SetScanlines(int on); +void I_SetVideoResolution(int w, int h); +void I_SetFullscreen(int on); +void I_SetAspectCorrect(int on); +int I_GetVideoFilter(void); +int I_GetScanlines(void); void closewindow(void); bool I_GetNeedResize(bool setonlypos); int I_VsyncActive(void); diff --git a/raptor/src/rap.cpp b/raptor/src/rap.cpp index 8d59bb83..382981d3 100644 --- a/raptor/src/rap.cpp +++ b/raptor/src/rap.cpp @@ -922,7 +922,22 @@ Do_Game( b3_flag = 0; } - if (KBD_IsKey(SC_P) || JOY_IsKeyInGameStart(Start)) + if (KBD_IsKey(SC_O)) + { + SWD_SetClearFlag(0); + IPT_End(); + WIN_Opts(); + IPT_Start(); + RAP_ClearSides(); + RAP_DisplayStats(); + SWD_SetClearFlag(1); + IMS_StartAck(); + BUT_1 = BUT_2 = BUT_3 = BUT_4 = 0; + b2_flag = 0; + b3_flag = 0; + } + + if (KBD_IsKey(SC_P) || JOY_IsKeyInGameStart(Start)) { while (IMS_IsAck()) { diff --git a/raptor/src/shots.cpp b/raptor/src/shots.cpp index 2438a63e..70e8f86f 100644 --- a/raptor/src/shots.cpp +++ b/raptor/src/shots.cpp @@ -633,7 +633,10 @@ SHOTS_PlayerShoot( cur->move.y2 = 0; cur->startx = player_cx; cur->starty = player_cy; - ANIMS_StartAnim(A_PLAYER_SHOOT, o_gun1[playerpic], 0); + // Casing streaks drop into world space (StartGAnim takes the + // top-left; the 8x8 GUNSTR art is centered on the muzzle by hand). + ANIMS_StartGAnim(A_PLAYER_EJECTA, + player_cx + o_gun1[playerpic] - 4, player_cy - 4); cur = SHOTS_Get(); if (!cur) @@ -651,7 +654,8 @@ SHOTS_PlayerShoot( cur->move.y2 = 0; cur->startx = player_cx; cur->starty = player_cy; - ANIMS_StartAnim(A_PLAYER_SHOOT, -o_gun1[playerpic] - 1, 0); + ANIMS_StartGAnim(A_PLAYER_EJECTA, + player_cx - o_gun1[playerpic] - 1 - 4, player_cy - 4); break; case S_PLASMA_GUNS: diff --git a/raptor/src/windows.cpp b/raptor/src/windows.cpp index 590e2670..4a174b56 100644 --- a/raptor/src/windows.cpp +++ b/raptor/src/windows.cpp @@ -1,5 +1,6 @@ #include #include +#include "SDL.h" #include "common.h" #include "glbapi.h" #include "gfxapi.h" @@ -182,6 +183,178 @@ WIN_OptDraw( GFX_PutSprite((char*)GLB_GetItem(FILE127_SLIDE_PIC), x + opt_vol[FX_VOL] - 2, y); } +// WIN_Opts value rows: field + selection icon, keyboard order (matches the +// dialog, top to bottom). The last two are the volume slider rows. + +#define OPT_ROWS 8 +#define OPT_VALUE_ROWS 6 +#define FILTER_COUNT 4 + +static const int opt_filter_list[FILTER_COUNT] = { 0, 2, 3, 4 }; + +static const struct +{ + int field; + int icon; +} opt_rows[OPT_ROWS] = { + {OPTS_DETAIL, OPTS_PIC1}, + {OPTS_FILTER, OPTS_PIC4}, + {OPTS_SCANLINES, OPTS_PIC5}, + {OPTS_RES, OPTS_PIC6}, + {OPTS_FULLSCR, OPTS_PIC7}, + {OPTS_ASPECT, OPTS_PIC8}, + {OPTS_VMUSIC, OPTS_PIC2}, + {OPTS_VFX, OPTS_PIC3} +}; + +static const struct +{ + int w, h; +} res_list[] = { + {640, 480}, {800, 600}, {960, 720}, {1280, 960}, + {1600, 1200}, {1920, 1440} +}; + +#define RES_COUNT ((int)(sizeof(res_list) / sizeof(res_list[0]))) + +static const char opt_detail_txt[2][16] = { + "LOW DETAIL", + "HIGH DETAIL" +}; + +static int opt_curd; +static int opt_filter_idx; +static int opt_scan; +static int opt_res_idx; +static int opt_avail[RES_COUNT]; +static int opt_fullscr; +static int opt_aspect; + +static const char *OPTS_FilterText(char *buf) +{ + if (opt_filter_list[opt_filter_idx] == 0) + strcpy(buf, "FILTER OFF"); + else + sprintf(buf, "FILTER SCALE %dX", opt_filter_list[opt_filter_idx]); + return buf; +} + +static const char *OPTS_ScanText(char *buf) +{ + strcpy(buf, opt_scan ? "SCANLINES ON" : "SCANLINES OFF"); + return buf; +} + +static const char *OPTS_ResText(char *buf) +{ + sprintf(buf, "RESOLUTION %dX%d", res_list[opt_res_idx].w, + res_list[opt_res_idx].h); + return buf; +} + +static const char *OPTS_FullscrText(char *buf) +{ + strcpy(buf, opt_fullscr ? "FULLSCREEN ON" : "FULLSCREEN OFF"); + return buf; +} + +static const char *OPTS_AspectText(char *buf) +{ + strcpy(buf, opt_aspect ? "ASPECT 4:3 ON" : "ASPECT 4:3 OFF"); + return buf; +} + +/*************************************************************************** +OPTS_ShowRow() - Moves the selection arrow icon to a row + ***************************************************************************/ +static void +OPTS_ShowRow( + int row +) +{ + int loop; + + for (loop = 0; loop < OPT_ROWS; loop++) + SWD_SetFieldItem(opt_window, opt_rows[loop].icon, -1); + + SWD_SetFieldItem(opt_window, opt_rows[row].icon, FILE128_POINT_PIC); + SWD_ShowAllWindows(); + GFX_DisplayUpdate(); +} + +/*************************************************************************** +OPTS_StepRow() - Apply one LEFT(-1)/RIGHT(+1) step to an options row: +updates the row state, live-applies the video setting and refreshes the +field text. Returns 1 when the caller should play the slider patch sound. + ***************************************************************************/ +static int +OPTS_StepRow( + int field, + int dir +) +{ + char buf[24]; + + switch (field) + { + case OPTS_VMUSIC: + opt_vol[MUSIC_VOL] += dir * 8; + if (opt_vol[MUSIC_VOL] < 0) + opt_vol[MUSIC_VOL] = 0; + if (opt_vol[MUSIC_VOL] > 127) + opt_vol[MUSIC_VOL] = 127; + return 0; + + case OPTS_VFX: + opt_vol[FX_VOL] += dir * 8; + if (opt_vol[FX_VOL] < 0) + opt_vol[FX_VOL] = 0; + if (opt_vol[FX_VOL] > 127) + opt_vol[FX_VOL] = 127; + return 1; + + case OPTS_DETAIL: + opt_curd ^= 1; + SWD_SetFieldText(opt_window, OPTS_DETAIL, opt_detail_txt[opt_curd]); + return 0; + + case OPTS_FILTER: + opt_filter_idx = (opt_filter_idx + dir + FILTER_COUNT) % FILTER_COUNT; + I_SetVideoFilter(opt_filter_list[opt_filter_idx]); + SWD_SetFieldText(opt_window, OPTS_FILTER, OPTS_FilterText(buf)); + return 0; + + case OPTS_SCANLINES: + opt_scan ^= 1; + I_SetScanlines(opt_scan); + SWD_SetFieldText(opt_window, OPTS_SCANLINES, OPTS_ScanText(buf)); + return 0; + + case OPTS_RES: + do + { + opt_res_idx = (opt_res_idx + dir + RES_COUNT) % RES_COUNT; + } while (!opt_avail[opt_res_idx]); + I_SetVideoResolution(res_list[opt_res_idx].w, res_list[opt_res_idx].h); + SWD_SetFieldText(opt_window, OPTS_RES, OPTS_ResText(buf)); + return 0; + + case OPTS_FULLSCR: + opt_fullscr ^= 1; + I_SetFullscreen(opt_fullscr); + SWD_SetFieldText(opt_window, OPTS_FULLSCR, OPTS_FullscrText(buf)); + return 0; + + case OPTS_ASPECT: + opt_aspect ^= 1; + I_SetAspectCorrect(opt_aspect); + SWD_SetFieldText(opt_window, OPTS_ASPECT, OPTS_AspectText(buf)); + return 0; + } + + return 0; +} + /*************************************************************************** WIN_Opts() - Sets Game Options ***************************************************************************/ @@ -192,35 +365,79 @@ WIN_Opts( { SWD_DLG dlg; int x, y, lx, ly; - int kbactive, patchflag, curd, cur_field; + int kbactive, patchflag, cur_field; int new_vol; - int fpics[3] = { - OPTS_PIC1, OPTS_PIC2, OPTS_PIC3 - }; - char detail[2][16] = { - "LOW DETAIL", - "HIGH DETAIL" - }; + int loop, diff, best; + SDL_DisplayMode desktop; + char buf[24]; kbactive = 0; patchflag = 0; - curd = opt_detail; cur_field = 0; opt_vol[MUSIC_VOL] = music_volume; opt_vol[FX_VOL] = fx_volume; + // Video row state from the live settings + + opt_curd = opt_detail; + opt_filter_idx = 0; + + for (loop = 0; loop < FILTER_COUNT; loop++) + { + if (opt_filter_list[loop] == I_GetVideoFilter()) + opt_filter_idx = loop; + } + + opt_scan = I_GetScanlines(); + opt_fullscr = fullscreen; + opt_aspect = aspect_ratio_correct; + + // Resolution presets: skip anything larger than the desktop, start on + // the preset closest in area to the current window size + + if (SDL_GetDesktopDisplayMode(0, &desktop) != 0) + desktop.w = desktop.h = 0; + + best = 0x7fffffff; + opt_res_idx = 0; + + for (loop = 0; loop < RES_COUNT; loop++) + { + opt_avail[loop] = 1; + + if (desktop.w && (res_list[loop].w > desktop.w + || res_list[loop].h > desktop.h)) + { + opt_avail[loop] = 0; + continue; + } + + diff = res_list[loop].w * res_list[loop].h + - window_width * window_height; + + if (diff < 0) + diff = -diff; + + if (diff < best) + { + best = diff; + opt_res_idx = loop; + } + } + opt_window = SWD_InitWindow(FILE13f_OPTS_SWD); - SWD_SetFieldText(opt_window, OPTS_DETAIL, detail[curd]); + SWD_SetFieldText(opt_window, OPTS_DETAIL, opt_detail_txt[opt_curd]); + SWD_SetFieldText(opt_window, OPTS_FILTER, OPTS_FilterText(buf)); + SWD_SetFieldText(opt_window, OPTS_SCANLINES, OPTS_ScanText(buf)); + SWD_SetFieldText(opt_window, OPTS_RES, OPTS_ResText(buf)); + SWD_SetFieldText(opt_window, OPTS_FULLSCR, OPTS_FullscrText(buf)); + SWD_SetFieldText(opt_window, OPTS_ASPECT, OPTS_AspectText(buf)); SWD_SetWinDrawFunc(opt_window, WIN_OptDraw); - SWD_SetFieldItem(opt_window, OPTS_PIC1, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC2, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC3, -1); - - SWD_ShowAllWindows(); - + OPTS_ShowRow(0); + SND_Patch(FX_SWEP, 127); GFX_DisplayUpdate(); @@ -284,29 +501,11 @@ WIN_Opts( break; case SC_LEFT: - if (cur_field) - { - if (cur_field == 2) - patchflag = 1; - opt_vol[cur_field - 1] -= 8; - if (opt_vol[cur_field - 1] < 0) - opt_vol[cur_field - 1] = 0; - SWD_ShowAllWindows(); - GFX_DisplayUpdate(); - } - break; - case SC_RIGHT: - if (cur_field) - { - if (cur_field == 2) - patchflag = 1; - opt_vol[cur_field - 1] += 8; - if (opt_vol[cur_field - 1] > 127) - opt_vol[cur_field - 1] = 127; - SWD_ShowAllWindows(); - GFX_DisplayUpdate(); - } + patchflag = OPTS_StepRow(opt_rows[cur_field].field, + dlg.keypress == SC_RIGHT ? 1 : -1); + SWD_ShowAllWindows(); + GFX_DisplayUpdate(); break; case SC_UP: @@ -314,33 +513,23 @@ WIN_Opts( cur_field--; kbactive = 1; SND_Patch(FX_SWEP, 127); - SWD_SetFieldItem(opt_window, OPTS_PIC1, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC2, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC3, -1); - SWD_SetFieldItem(opt_window, fpics[cur_field], FILE128_POINT_PIC); - SWD_ShowAllWindows(); - GFX_DisplayUpdate(); + OPTS_ShowRow(cur_field); break; case SC_DOWN: - if (kbactive && cur_field < 2) + if (kbactive && cur_field < OPT_ROWS - 1) cur_field++; kbactive = 1; SND_Patch(FX_SWEP, 127); - SWD_SetFieldItem(opt_window, OPTS_PIC1, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC2, -1); - SWD_SetFieldItem(opt_window, OPTS_PIC3, -1); - SWD_SetFieldItem(opt_window, fpics[cur_field], FILE128_POINT_PIC); - SWD_ShowAllWindows(); - GFX_DisplayUpdate(); + OPTS_ShowRow(cur_field); break; case SC_ENTER: - if (!cur_field) + if (cur_field < OPT_VALUE_ROWS) { dlg.cur_act = S_FLD_COMMAND; dlg.cur_cmd = F_SELECT; - dlg.field = OPTS_DETAIL; + dlg.field = opt_rows[cur_field].field; } break; } @@ -387,7 +576,7 @@ WIN_Opts( } } - opt_detail = curd; + opt_detail = opt_curd; if (fx_volume != opt_vol[FX_VOL]) fx_volume = opt_vol[FX_VOL]; @@ -414,13 +603,22 @@ WIN_Opts( INI_PutPreferenceLong("SoundFX", "Volume", fx_volume); } INI_PutPreferenceLong("Setup", "Detail", opt_detail); + INI_PutPreferenceLong("Video", "upscaler", I_GetVideoFilter()); + INI_PutPreferenceLong("Video", "scanlines", I_GetScanlines()); + INI_PutPreferenceLong("Video", "fullscreen", fullscreen); + INI_PutPreferenceLong("Video", "aspect_ratio_correct", + aspect_ratio_correct); SND_Patch(FX_SWEP, 127); goto exit_opts; case OPTS_DETAIL: + case OPTS_FILTER: + case OPTS_SCANLINES: + case OPTS_RES: + case OPTS_FULLSCR: + case OPTS_ASPECT: SND_Patch(FX_SWEP, 127); - curd ^= 1; - SWD_SetFieldText(opt_window, OPTS_DETAIL, detail[curd]); + OPTS_StepRow(dlg.field, 1); SWD_ShowAllWindows(); GFX_DisplayUpdate(); break; @@ -431,8 +629,6 @@ WIN_Opts( SND_Patch(FX_SWEP, 127); } -exit_opts: - SWD_SetWinDrawFunc(opt_window, NULL); SWD_DestroyWindow(opt_window); diff --git a/raptor/src/windows.h b/raptor/src/windows.h index 721ec733..d9fc1f76 100644 --- a/raptor/src/windows.h +++ b/raptor/src/windows.h @@ -25,6 +25,7 @@ void WIN_AskExit(void); void WIN_MainMenu(void); void WIN_MainLoop(void); void WIN_Pause(void); +void WIN_Opts(void); int WIN_AskBool(const char *question); void WIN_WinGame(int game); void WIN_Msg(const char *msg); diff --git a/raptor/src/winids.h b/raptor/src/winids.h index ea2fbb32..72e0af77 100644 --- a/raptor/src/winids.h +++ b/raptor/src/winids.h @@ -194,3 +194,13 @@ #define OPTS_000A 0x000a // TEXT FIELD #define OPTS_VMUSIC 0x000b // VIEW AREA #define OPTS_VFX 0x000c // VIEW AREA +#define OPTS_FILTER 0x000d // BUTTON FIELD +#define OPTS_SCANLINES 0x000e // BUTTON FIELD +#define OPTS_RES 0x000f // BUTTON FIELD +#define OPTS_FULLSCR 0x0010 // BUTTON FIELD +#define OPTS_ASPECT 0x0011 // BUTTON FIELD +#define OPTS_PIC4 0x0012 // ICON +#define OPTS_PIC5 0x0013 // ICON +#define OPTS_PIC6 0x0014 // ICON +#define OPTS_PIC7 0x0015 // ICON +#define OPTS_PIC8 0x0016 // ICON diff --git a/tools/opts_swd_spec.py b/tools/opts_swd_spec.py new file mode 100644 index 00000000..e73c8dd6 --- /dev/null +++ b/tools/opts_swd_spec.py @@ -0,0 +1,104 @@ +"""Spec for the re-authored data/ui/OPTS_SWD (catalog handle 0x1003f). + +Same window/dialog as the original, grown from 13 to 23 fields: the original +Music/FX/Detail rows plus FILTER, SCANLINES, RESOLUTION, FULLSCREEN and +ASPECT rows and their selection icons. Window-relative coordinates assume +the window at x=60, y=6, 200x188 (320x200 screen space). + +Layout (window-relative): + y=8 OPTIONS title (field 8) + y=20 panel bumpin behind rows (field 2, 5,20 190x100) + y=24 DETAIL row + PIC1 (fields 6, 3) + y=40 FILTER row + PIC4 (13, 18) + y=56 SCANLINES row + PIC5 (14, 19) + y=72 RES row + PIC6 (15, 20) + y=88 FULLSCR row + PIC7 (16, 21) + y=104 ASPECT row + PIC8 (17, 22) + y=122 MUSIC VOLUME label (9) + y=131 music slider view/track (11, 0) + y=146 SOUND FX VOLUME label (10) + y=160 fx slider view/track (12, 1) + y=174 EXIT button (7) + +Build: python3 tools/swdbuild.py build tools/opts_swd_spec.py data/ui/OPTS_SWD +(the printed size must be written into data/INDEX's `1003f` line) +""" + +from swdbuild import (BUMPIN_TEMPLATE, ICON_TEMPLATE, BUTTON_TEMPLATE, + TEXT_TEMPLATE, VIEWAREA_TEMPLATE) + +SWIN = { + "version": 1, "swdsize": 0, "arrowflag": 0, "display": 0, + "opt3": 0, "opt4": 0, "id": 2, "type": 0, + "name": "OPTS", "item_name": "TEXTURE_PIC", + "item": 65559, "picflag": 1, "lock": 1, "fldofs": 120, "txtofs": 0, + "firstfld": 0, "opt": 1, "color": 1, "numflds": 23, + "x": 60, "y": 6, "lx": 200, "ly": 188, "shadow": 1, + # swdsize/txtofs are recomputed by the builder (file size / blob offset). +} + +# All rows share the button look; value texts are overwritten at runtime via +# SWD_SetFieldText, so the stored strings are just the initial/label values. +_ROW = {**BUTTON_TEMPLATE, "x": 30, "lx": 140, "maxchars": 22} +_ICON = {**ICON_TEMPLATE, "x": 12, "y": 24} +_LABEL = {**TEXT_TEMPLATE, "x": 30} + +FIELDS = [ + # 0-2: bumpins (slider tracks + row panel), repositioned originals + {**BUMPIN_TEMPLATE, "x": 30, "y": 136, "lx": 127, "ly": 3}, + {**BUMPIN_TEMPLATE, "x": 30, "y": 165, "lx": 127, "ly": 3}, + {**BUMPIN_TEMPLATE, "x": 5, "y": 20, "lx": 190, "ly": 100}, + + # 3-5: selection icons for detail / music / fx rows + {**_ICON, "name": "PIC1", "y": 24}, + {**_ICON, "name": "PIC2", "y": 122}, + {**_ICON, "name": "PIC3", "y": 146}, + + # 6-7: buttons + {**_ROW, "name": "DETAIL", "hotkey": 0x20, "y": 24, "text_index": 2}, + {**_ROW, "name": "EXIT", "id": 4, "hotkey": 0, "x": 118, "y": 174, + "lx": 57, "maxchars": 5, "text_index": 3}, + + # 8-10: texts + {**TEXT_TEMPLATE, "id": 200, "input_opt": 1, "item_name": "", + "font_name": "FONT1_FNT", "fontid": 0x10003, "x": 68, "y": 8, + "lx": 71, "ly": 14, "maxchars": 8, "text_index": 4}, + {**_LABEL, "id": 150, "y": 122, "lx": 104, "maxchars": 13, + "text_index": 5}, + {**_LABEL, "id": 160, "input_opt": 1, "y": 146, "lx": 112, + "maxchars": 16, "text_index": 6}, + + # 11-12: volume slider view areas + {**VIEWAREA_TEMPLATE, "name": "VMUSIC", "x": 30, "y": 131}, + {**VIEWAREA_TEMPLATE, "name": "VFX", "x": 30, "y": 160}, + + # 13-17: video option rows (hotkeys: F, L, R, -, A) + {**_ROW, "name": "FILTER", "hotkey": 0x21, "y": 40, "text_index": 7}, + {**_ROW, "name": "SCANLINES", "hotkey": 0x26, "y": 56, "text_index": 8}, + {**_ROW, "name": "RES", "hotkey": 0x13, "y": 72, "text_index": 9}, + {**_ROW, "name": "FULLSCR", "hotkey": 0, "y": 88, "text_index": 10}, + {**_ROW, "name": "ASPECT", "hotkey": 0x1E, "y": 104, "text_index": 11}, + + # 18-22: selection icons for the five video rows + {**_ICON, "name": "PIC4", "y": 40}, + {**_ICON, "name": "PIC5", "y": 56}, + {**_ICON, "name": "PIC6", "y": 72}, + {**_ICON, "name": "PIC7", "y": 88}, + {**_ICON, "name": "PIC8", "y": 104}, +] + +# 0-1 ("Y","N") are the original bumpin marks; nothing references them at +# runtime but they are kept for parity with the extracted asset. +TEXTS = [ + "Y", "N", + "DETAIL HIGH ", # 2: overwritten at open by WIN_Opts + "EXIT", # 3 + "OPTIONS", # 4 + "MUSIC VOLUME", # 5 + "SOUND FX VOLUME", # 6 + "FILTER SCALE 3X", # 7: overwritten at open + "SCANLINES OFF", # 8: overwritten at open + "RESOLUTION 960X720", # 9: overwritten at open + "FULLSCREEN OFF", # 10: overwritten at open + "ASPECT 4:3 ON", # 11: overwritten at open +] diff --git a/tools/swdbuild.py b/tools/swdbuild.py new file mode 100755 index 00000000..1ef6e161 --- /dev/null +++ b/tools/swdbuild.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Dump and build Raptor .SWD dialog assets. + +On-disk format (little-endian), mirrored from src/swdapi.h: + + SWIN header 120 bytes <8i 16s 16s 14i> + SFIELD32[] 148 bytes <8i 16s 16s i 16s 15i i> (sptr -> PlaceHolder) + text blob NUL-padded strings + +Each TEXT/BUTTON/INPUT/DRAGBAR field stores its text at `txtoff` RELATIVE +TO ITS OWN RECORD (SWD_ReformatFieldData compensates the 64-bit struct +growth with `(numflds - i) * 4`, which only closes under that reading). +Other field types never dereference txtoff. + +Modes: + dump pretty-print SWIN, fields, text blob + build assemble a .swd from a Python spec + +Spec module contract (see tools/opts_swd_spec.py): + from swdbuild import BUTTON_TEMPLATE, ... # clone-source records + SWIN = {...} # all 24 ints + name/item_name strings + FIELDS = [ {...}, ... ] # {**TEMPLATE, **overrides}; "text_index" links + # the field to TEXTS[i] and computes txtoff + TEXTS = ["...", ...] # blob entries, laid out in list order +""" + +import argparse +import importlib.util +import struct +import sys +from pathlib import Path + +SWIN_STRUCT = struct.Struct("<8i16s16s14i") +FIELD_STRUCT = struct.Struct("<8i16s16si16s15ii") + +SWIN_KEYS = [ + "version", "swdsize", "arrowflag", "display", "opt3", "opt4", "id", "type", + "name", "item_name", + "item", "picflag", "lock", "fldofs", "txtofs", "firstfld", "opt", "color", + "numflds", "x", "y", "lx", "ly", "shadow", +] +FIELD_KEYS = [ + "opt", "id", "hotkey", "kbflag", "opt3", "opt4", "input_opt", "bstatus", + "name", "item_name", "item", "font_name", + "fontid", "fontbasecolor", "maxchars", "picflag", "color", "lite", "mark", + "saveflag", "shadow", "selectable", "x", "y", "lx", "ly", +] + +FLD_TEXT, FLD_BUTTON, FLD_INPUT, FLD_DRAGBAR = 1, 2, 5, 6 # txtoff-carrying + + +def _s(raw: bytes) -> str: + return raw.split(b"\0", 1)[0].decode("ascii", "replace") + + +def _pack_str(text, field): + raw = text.encode("ascii") + if len(raw) > 16: + raise ValueError(f"{field}: string longer than 16 chars: {text!r}") + return raw.ljust(16, b"\0") + + +def _check_keys(where, given, required, optional=()): + missing = [k for k in required if k not in given] + extra = [k for k in given if k not in required and k not in optional] + if missing or extra: + raise ValueError(f"{where}: missing {missing}, unknown {extra}") + +BUMPIN_TEMPLATE = { + "opt": 7, "id": 0, "hotkey": 0, "kbflag": 10, "opt3": 0, "opt4": 0, + "input_opt": 0, "bstatus": 0, + "name": "", "item_name": "", + "item": 0, "font_name": "SYSTEM_FNT", "fontid": 5, "fontbasecolor": 0, + "maxchars": 3, "picflag": 0, "color": 1, "lite": 15, "mark": 0, + "saveflag": 0, "shadow": 0, "selectable": 0, + "x": 0, "y": 0, "lx": 127, "ly": 3, +} +ICON_TEMPLATE = { + "opt": 9, "id": 0, "hotkey": 0, "kbflag": 30, "opt3": 0, "opt4": 0, + "input_opt": 0, "bstatus": 0, + "name": "PIC1", "item_name": "", + "item": -1, "font_name": "SYSTEM_FNT", "fontid": 5, "fontbasecolor": 0, + "maxchars": 1, "picflag": 2, "color": 8, "lite": 15, "mark": 0, + "saveflag": 0, "shadow": 0, "selectable": 0, + "x": 11, "y": 35, "lx": 16, "ly": 11, +} +BUTTON_TEMPLATE = { + "opt": 2, "id": 1, "hotkey": 0x20, "kbflag": 80, "opt3": 0, "opt4": 0, + "input_opt": 0, "bstatus": 0, + "name": "DETAIL", "item_name": "STEXTURE_PIC", + "item": 0x10024, "font_name": "FONT2_FNT", "fontid": 0x10004, + "fontbasecolor": 82, "maxchars": 13, "picflag": 1, "color": 8, "lite": 15, + "mark": 0, "saveflag": 0, "shadow": 1, "selectable": 0, + "x": 35, "y": 35, "lx": 118, "ly": 12, +} +TEXT_TEMPLATE = { + "opt": 1, "id": 150, "hotkey": 0, "kbflag": 90, "opt3": 0, "opt4": 0, + "input_opt": 0, "bstatus": 0, + "name": "", "item_name": "", + "item": 0, "font_name": "FONT2_FNT", "fontid": 0x10004, "fontbasecolor": 82, + "maxchars": 13, "picflag": 0, "color": 8, "lite": 15, "mark": 0, + "saveflag": 0, "shadow": 0, "selectable": 0, + "x": 35, "y": 57, "lx": 104, "ly": 8, +} +VIEWAREA_TEMPLATE = { + "opt": 11, "id": 0, "hotkey": 0, "kbflag": 110, "opt3": 0, "opt4": 0, + "input_opt": 0, "bstatus": 0, + "name": "VMUSIC", "item_name": "", + "item": 0, "font_name": "SYSTEM_FNT", "fontid": 5, "fontbasecolor": 0, + "maxchars": 1, "picflag": 0, "color": 8, "lite": 15, "mark": 0, + "saveflag": 0, "shadow": 0, "selectable": 0, + "x": 35, "y": 68, "lx": 127, "ly": 13, +} +def parse(path: Path): + """Return (swin dict, field dicts, blob bytes) for a .swd file.""" + data = path.read_bytes() + if len(data) < SWIN_STRUCT.size: + raise ValueError(f"{path}: {len(data)} bytes, shorter than SWIN header") + + raw = SWIN_STRUCT.unpack_from(data, 0) + swin = dict(zip(SWIN_KEYS[:8], raw[:8])) + swin["name"] = _s(raw[8]) + swin["item_name"] = _s(raw[9]) + swin.update(dict(zip(SWIN_KEYS[10:], raw[10:]))) + + numflds = swin["numflds"] + fields = [] + for i in range(numflds): + off = swin["fldofs"] + i * FIELD_STRUCT.size + if off + FIELD_STRUCT.size > len(data): + raise ValueError(f"{path}: field {i} at {off} past EOF") + fr = FIELD_STRUCT.unpack_from(data, off) + fld = dict(zip(FIELD_KEYS[:8], fr[:8])) + fld["name"] = _s(fr[8]) + fld["item_name"] = _s(fr[9]) + fld["item"] = fr[10] + fld["font_name"] = _s(fr[11]) + fld.update(dict(zip(FIELD_KEYS[12:], fr[12:26]))) + fld["txtoff"] = fr[26] + fld["_offset"] = off + fields.append(fld) + + blob_start = swin["fldofs"] + numflds * FIELD_STRUCT.size + return swin, fields, data[blob_start:] + + +def dump(path: Path) -> None: + swin, fields, blob = parse(path) + print(f"file: {path} ({path.stat().st_size} bytes, blob at " + f"{SWIN_STRUCT.size + len(fields) * FIELD_STRUCT.size})") + print("SWIN:") + for k in SWIN_KEYS: + print(f" {k:<12} {swin[k]!r}") + for i, fld in enumerate(fields): + print(f"field[{i:2d}] at +{fld['_offset']}:") + for k in FIELD_KEYS: + print(f" {k:<14} {fld[k]!r}") + if fld["opt"] in (FLD_TEXT, FLD_BUTTON, FLD_INPUT, FLD_DRAGBAR): + blob_start = SWIN_STRUCT.size + len(fields) * FIELD_STRUCT.size + t = blob[fld["_offset"] + fld["txtoff"] - blob_start:] + text = t.split(b"\0", 1)[0].decode("ascii", "replace") + print(f" text {text!r}") + print() + + +def build(spec_path: Path, out_path: Path) -> None: + # Import swdbuild itself under its own name so specs can pull templates. + self_spec = importlib.util.spec_from_file_location( + "swdbuild", Path(__file__).resolve()) + self_mod = importlib.util.module_from_spec(self_spec) + sys.modules["swdbuild"] = self_mod + self_spec.loader.exec_module(self_mod) + spec = importlib.util.spec_from_file_location("swd_spec", spec_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + swin_in, fields_in, texts = mod.SWIN, mod.FIELDS, mod.TEXTS + _check_keys("SWIN", swin_in, SWIN_KEYS) + if len(fields_in) != swin_in["numflds"]: + raise ValueError(f"SWIN.numflds={swin_in['numflds']} but " + f"len(FIELDS)={len(fields_in)}") + if swin_in["fldofs"] != SWIN_STRUCT.size: + raise ValueError(f"SWIN.fldofs must be {SWIN_STRUCT.size}") + + # Lay out the text blob: entry i is padded to the max slot size its + # referencing fields need (SWD_SetFieldText writes maxchars bytes). + slot_size = [len(t.encode("ascii")) + 1 for t in texts] + for i, fld in enumerate(fields_in): + _check_keys(f"FIELDS[{i}] (after template merge)", fld, + FIELD_KEYS, optional=("text_index",)) + ti = fld.get("text_index") + if ti is not None: + if not 0 <= ti < len(texts): + raise ValueError(f"FIELDS[{i}]: text_index {ti} out of range") + slot_size[ti] = max(slot_size[ti], fld["maxchars"]) + + blob = bytearray() + slot_off = [] + for text, size in zip(texts, slot_size): + raw = text.encode("ascii") + if len(raw) >= size: + raise ValueError(f"TEXTS entry {text!r} does not fit slot {size}") + slot_off.append(len(blob)) + blob += raw.ljust(size, b"\0") + blob_start = SWIN_STRUCT.size + len(fields_in) * FIELD_STRUCT.size + + # Header bookkeeping: total file size and blob offset (the original + # asset stores exactly these; specs pass 0 and get the real values). + swin = dict(swin_in) + swin["swdsize"] = blob_start + len(blob) + swin["txtofs"] = blob_start + + out = bytearray() + swin_vals = [swin[k] for k in SWIN_KEYS[:8]] + out += SWIN_STRUCT.pack(*swin_vals, + _pack_str(swin["name"], "SWIN.name"), + _pack_str(swin["item_name"], "SWIN.item_name"), + *[swin[k] for k in SWIN_KEYS[10:]]) + + for i, fld in enumerate(fields_in): + rec_off = SWIN_STRUCT.size + i * FIELD_STRUCT.size + txtoff = 0 + ti = fld.get("text_index") + if ti is not None: + txtoff = blob_start + slot_off[ti] - rec_off + out += FIELD_STRUCT.pack( + *[fld[k] for k in FIELD_KEYS[:8]], + _pack_str(fld["name"], f"FIELDS[{i}].name"), + _pack_str(fld["item_name"], f"FIELDS[{i}].item_name"), + fld["item"], + _pack_str(fld["font_name"], f"FIELDS[{i}].font_name"), + *[fld[k] for k in FIELD_KEYS[12:]], + txtoff, 0) + + assert len(out) == blob_start + out += blob + out_path.write_bytes(out) + print(f"{out_path}: {len(out)} bytes " + f"({len(fields_in)} fields, {len(texts)} text slots)") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + sub = ap.add_subparsers(dest="mode", required=True) + sub.add_parser("dump").add_argument("swd", type=Path) + bp = sub.add_parser("build") + bp.add_argument("spec", type=Path) + bp.add_argument("out", type=Path) + args = ap.parse_args() + + if args.mode == "dump": + dump(args.swd) + else: + build(args.spec, args.out) + + +if __name__ == "__main__": + main()