fix(ui): preserve the original canvas and display styling

High-DPI sizing and a resizable client allowed the 640x460 canvas to be placed
inside a much taller window, producing the large internal black band captured in
the gameplay recording. Open an aspect-correct fixed 960x690 client with high-DPI
framebuffer scaling disabled, giving every platform a predictable 1.5x canvas.

Use exact active-table patches for the cyan score fields and derive transparent
numeral glyphs from the recovered Win16 bitmap font. Fill the complete display
slots, move the KBytes readout clear of its border, and place shortcuts on a
readable footer so no dynamic text is clipped or left over an inactive panel.

Test Plan:
- `cargo fmt -- --check` -- passed
- `cargo test --all-targets` -- passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- launched the Linux window and confirmed a 960x690 client with no letterbox,
  full-width cyan fields, recovered score digits, and readable footer -- passed
- `git diff --check` -- passed
This commit is contained in:
2026-08-22 17:03:46 +02:00
parent cf2c839f83
commit e815391980
5 changed files with 92 additions and 28 deletions
+4
View File
@@ -5,6 +5,10 @@ Machine**. It uses the extracted original artwork and sound, recreates the
playfield as a fixed-step simulation, and runs from the same source on Linux,
macOS, and Windows.
The game opens at 960x690, an exact 1.5x enlargement of its original 640x460
canvas, so modern desktop scaling cannot distort or vertically offset the
pixel-art presentation.
The original program is not required at runtime. All required game assets are
embedded in the executable at build time.
+1 -1
View File
@@ -28,7 +28,7 @@ implementation.
| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. |
| High scores | Compatible import | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names, sorted, then migrated to portable JSON. |
| Configuration | Behaviorally compatible | Sound, language, and five detail levels are retained. Storage moves from a local Win16 INI file to the platform user-data directory. |
| Windows UI shell | Deliberately modernized | Win16 menus, modal dialogs, GDI blitting, and multimedia timers are replaced by a resizable, letterboxed native window with keyboard overlays. The visible game and original help remain at 640x460 logical pixels. |
| Windows UI shell | Deliberately modernized | Win16 menus, modal dialogs, GDI blitting, and multimedia timers are replaced by an aspect-correct 960x690 native window with keyboard overlays. The visible game and original help remain at 640x460 logical pixels. |
## Extracted asset inventory
+63 -21
View File
@@ -559,24 +559,33 @@ impl App {
},
);
}
draw_text("F1 HELP F2 SETUP", 345.0, 448.0, 11.0, BLACK);
draw_rectangle(
340.0,
444.0,
292.0,
14.0,
Color::from_rgba(214, 214, 214, 235),
);
draw_text("F1 HELP F2 SETUP", 347.0, 455.0, 10.0, BLACK);
}
#[allow(clippy::cast_precision_loss)]
fn draw_displays(&self, game: &Game) {
draw_rectangle(
draw_texture_ex(
&self.assets.active_table,
371.0,
235.0,
222.0,
31.0,
Color::from_rgba(0, 191, 209, 255),
233.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(244.0, 34.0)),
source: Some(Rect::new(371.0, 233.0, 244.0, 34.0)),
..Default::default()
},
);
draw_text(
format!("BONUS {:07}", game.bonus),
374.0,
258.0,
24.0,
BLACK,
self.draw_digits(
&format!("{:07}", game.bonus),
vec2(458.0, 240.0),
vec2(10.0, 18.0),
);
let media_level = game.player().media_level;
@@ -587,20 +596,31 @@ impl App {
draw_text(
format!("{:07} KBytes", game.player().score / 1_000),
395.0,
211.0,
16.0,
205.0,
14.0,
WHITE,
);
for (index, player) in game.players.iter().enumerate() {
let y = 274.0 + index as f32 * 44.0;
let panel = if index == game.current_player {
Color::from_rgba(0, 191, 209, 255)
} else {
Color::from_rgba(36, 82, 137, 255)
};
draw_rectangle(472.0, y, 121.0, 30.0, panel);
draw_text(format!("{:09}", player.score), 478.0, y + 21.0, 18.0, BLACK);
if index == game.current_player {
draw_texture_ex(
&self.assets.active_table,
472.0,
y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(143.0, 30.0)),
source: Some(Rect::new(472.0, y, 143.0, 30.0)),
..Default::default()
},
);
}
self.draw_digits(
&format!("{:09}", player.score),
vec2(480.0, y + 6.0),
vec2(10.0, 18.0),
);
for ball in 0..6 {
let lit = ball < usize::from(player.balls + player.extra_balls);
draw_circle(
@@ -613,6 +633,28 @@ impl App {
}
}
fn draw_digits(&self, digits: &str, position: Vec2, digit_size: Vec2) {
let mut x = position.x;
for digit in digits.bytes() {
if !digit.is_ascii_digit() {
continue;
}
let source_x = f32::from(digit - b'0') * 16.0;
draw_texture_ex(
&self.assets.digits,
x,
position.y,
BLACK,
DrawTextureParams {
dest_size: Some(digit_size),
source: Some(Rect::new(source_x, 0.0, 16.0, 28.0)),
..Default::default()
},
);
x += digit_size.x;
}
}
#[allow(clippy::cast_precision_loss)]
fn draw_settings(&self) {
draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE);
+18
View File
@@ -14,6 +14,7 @@ pub struct Assets {
pub robot: Texture2D,
pub magnet: Texture2D,
pub ball: Texture2D,
pub digits: Texture2D,
sounds: Vec<(u16, Sound)>,
}
@@ -54,6 +55,8 @@ impl Assets {
include_bytes!("../assets/original/images/bitmap_00101.png"),
include_bytes!("../assets/original/images/bitmap_00102.png"),
);
let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
let sound_bytes: [(u16, &[u8]); 16] = [
(
@@ -139,6 +142,7 @@ impl Assets {
robot,
magnet,
ball,
digits,
sounds,
}
}
@@ -182,3 +186,17 @@ fn masked_texture(color_bytes: &[u8], mask_bytes: &[u8]) -> Texture2D {
texture.set_filter(FilterMode::Nearest);
texture
}
fn monochrome_texture(bytes: &[u8]) -> Texture2D {
let mut image =
Image::from_file_with_format(bytes, None).expect("embedded monochrome bitmap must decode");
let (pixels, remainder) = image.bytes.as_chunks_mut::<4>();
assert!(remainder.is_empty());
for pixel in pixels {
pixel[3] = u8::MAX - pixel[0];
pixel[..3].fill(u8::MAX);
}
let texture = Texture2D::from_image(&image);
texture.set_filter(FilterMode::Nearest);
texture
}
+6 -6
View File
@@ -7,16 +7,16 @@ mod persistence;
use app::App;
use macroquad::prelude::*;
const LOGICAL_WIDTH: i32 = 640;
const LOGICAL_HEIGHT: i32 = 460;
const WINDOW_WIDTH: i32 = 960;
const WINDOW_HEIGHT: i32 = 690;
fn window_conf() -> Conf {
Conf {
window_title: "TDK Pinball Machine".to_owned(),
window_width: LOGICAL_WIDTH,
window_height: LOGICAL_HEIGHT,
window_resizable: true,
high_dpi: true,
window_width: WINDOW_WIDTH,
window_height: WINDOW_HEIGHT,
window_resizable: false,
high_dpi: false,
..Default::default()
}
}