diff --git a/Cargo.lock b/Cargo.lock index ad8c30a..19afb19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "addr2line" version = "0.25.1" @@ -17,6 +33,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -97,6 +125,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-stream" version = "0.3.6" @@ -306,6 +346,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.11.1" @@ -635,12 +681,52 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "emath", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "ahash", + "bitflags 2.11.0", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-tiny-skia" +version = "0.1.0" +dependencies = [ + "egui", + "tiny-skia", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" + [[package]] name = "enable-ansi-support" version = "0.3.1" @@ -671,6 +757,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + [[package]] name = "equivalent" version = "1.0.2" @@ -693,6 +802,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field_accessor_as_string" version = "0.1.0" @@ -718,6 +836,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -1628,6 +1747,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nt-time" version = "0.14.0" @@ -1758,6 +1883,8 @@ version = "0.1.2" dependencies = [ "anyhow", "crossterm", + "egui", + "egui-tiny-skia", "enable-ansi-support", "encoding_rs", "field_accessor_as_string", @@ -1778,6 +1905,7 @@ dependencies = [ "regex", "retour", "serde", + "tiny-skia", "toml 0.9.12+spec-1.1.0", "tracing", "tracing-appender", @@ -1870,6 +1998,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "owo-colors" version = "4.2.3" @@ -1940,6 +2077,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2029,6 +2179,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.10.0" @@ -2682,6 +2838,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -2936,6 +3098,32 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -3238,6 +3426,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typed-path" version = "0.12.3" diff --git a/egui-tiny-skia/Cargo.toml b/egui-tiny-skia/Cargo.toml new file mode 100644 index 0000000..f122439 --- /dev/null +++ b/egui-tiny-skia/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "egui-tiny-skia" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +egui = { version = "0.33", default-features = false, features = ["default_fonts"] } +tiny-skia = "0.11" diff --git a/egui-tiny-skia/README.md b/egui-tiny-skia/README.md new file mode 100644 index 0000000..601735f --- /dev/null +++ b/egui-tiny-skia/README.md @@ -0,0 +1,44 @@ +# egui-tiny-skia + +A small, platform-agnostic `egui` backend that renders into a `tiny-skia` +`Pixmap`. + +This crate is intended for OpenZT UI experiments where the integration layer can +copy pixels into the game window. The pixmap is cleared to solid magenta before +each paint call so the Windows integration can treat untouched pixels as a +chroma-key transparent color. + +## Current scope + +- Owns an `egui::Context`, a `tiny_skia::Pixmap`, and a CPU texture store. +- Applies egui texture deltas. +- Tessellates egui shapes into clipped mesh primitives. +- Rasterizes mesh triangles on the CPU with vertex color, texture sampling, and + alpha blending. +- Handles `Primitive::Callback` as a no-op. + +Text is rendered through egui's normal font atlas texture path. There is no +separate `fontdue` glyph pipeline at the moment. + +## Run tests + +From the workspace root: + +```bash +cargo test --manifest-path egui-tiny-skia/Cargo.toml +``` + +## Render PNG example + +From the workspace root: + +```bash +cargo run --manifest-path egui-tiny-skia/Cargo.toml --example render_png +``` + +The example writes: + +```text +egui-tiny-skia/target/egui-tiny-skia-render.png +``` + diff --git a/egui-tiny-skia/examples/render_png.rs b/egui-tiny-skia/examples/render_png.rs new file mode 100644 index 0000000..89ab5db --- /dev/null +++ b/egui-tiny-skia/examples/render_png.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +use egui_tiny_skia::TinySkiaBackend; + +fn main() { + let mut backend = TinySkiaBackend::new(800, 600); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )), + ..Default::default() + }; + + let output = backend.run_frame(input, |ctx| { + egui::Window::new("test") + .fixed_pos(egui::pos2(20.0, 20.0)) + .fixed_size(egui::vec2(220.0, 120.0)) + .show(ctx, |ui| { + ui.heading("OpenZT"); + ui.label("egui rendered through tiny-skia"); + ui.separator(); + ui.horizontal(|ui| { + ui.label("Status:"); + ui.colored_label(egui::Color32::LIGHT_GREEN, "native render OK"); + }); + }); + }); + + let pixmap = backend.paint(output); + let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("egui-tiny-skia-render.png"); + std::fs::create_dir_all(output_path.parent().expect("png path must have a parent")) + .expect("failed to create output directory"); + pixmap.save_png(&output_path).expect("failed to save png"); + println!("{}", output_path.display()); +} diff --git a/egui-tiny-skia/src/lib.rs b/egui-tiny-skia/src/lib.rs new file mode 100644 index 0000000..1d836b7 --- /dev/null +++ b/egui-tiny-skia/src/lib.rs @@ -0,0 +1,82 @@ +mod painter; +mod texture; + +pub use texture::{AlphaKind, TextureStore, TextureStoreError}; + +use egui::epaint::{ClippedShape, Shape}; +use egui::{Context, FullOutput, RawInput}; +use tiny_skia::{Color, Pixmap}; + +pub struct TinySkiaBackend { + ctx: Context, + pixmap: Pixmap, + textures: TextureStore, +} + +impl TinySkiaBackend { + pub fn new(width: u32, height: u32) -> Self { + let pixmap = Pixmap::new(width, height).expect("pixmap dimensions must be non-zero"); + let ctx = Context::default(); + ctx.set_fonts(egui::FontDefinitions::default()); + let mut textures = TextureStore::default(); + let warmup_input = RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32))), + ..Default::default() + }; + let warmup_output = ctx.run(warmup_input, |_| {}); + textures.apply_delta(warmup_output.textures_delta).expect("failed to apply egui font texture delta"); + + Self { ctx, pixmap, textures } + } + + pub fn context(&self) -> &Context { + &self.ctx + } + + pub fn resize(&mut self, width: u32, height: u32) { + self.pixmap = Pixmap::new(width, height).expect("pixmap dimensions must be non-zero"); + } + + pub fn run_frame(&mut self, input: RawInput, mut ui: impl FnMut(&Context)) -> FullOutput { + let retry_input = input.clone(); + let output = self.ctx.run(input, |ctx| ui(ctx)); + + if should_retry_after_texture_upload(&output) { + self.textures + .apply_delta(output.textures_delta.clone()) + .expect("failed to apply egui texture delta before retry"); + self.ctx.run(retry_input, |ctx| ui(ctx)) + } else { + output + } + } + + pub fn paint(&mut self, output: FullOutput) -> &Pixmap { + self.pixmap.fill(Color::from_rgba8(0, 0, 0, 0)); + self.textures.apply_delta(output.textures_delta).expect("failed to apply egui texture delta"); + let primitives = self.ctx.tessellate(output.shapes, output.pixels_per_point); + painter::paint_primitives(&mut self.pixmap, &primitives, &self.textures); + + &self.pixmap + } + + pub fn pixmap(&self) -> &Pixmap { + &self.pixmap + } +} + +fn should_retry_after_texture_upload(output: &FullOutput) -> bool { + !output.textures_delta.set.is_empty() && !output.shapes.iter().any(shape_has_paint) +} + +fn shape_has_paint(shape: &ClippedShape) -> bool { + unclipped_shape_has_paint(&shape.shape) +} + +fn unclipped_shape_has_paint(shape: &Shape) -> bool { + match shape { + Shape::Noop => false, + Shape::Vec(shapes) => shapes.iter().any(unclipped_shape_has_paint), + _ => true, + } +} diff --git a/egui-tiny-skia/src/painter.rs b/egui-tiny-skia/src/painter.rs new file mode 100644 index 0000000..4709957 --- /dev/null +++ b/egui-tiny-skia/src/painter.rs @@ -0,0 +1,506 @@ +use egui::epaint::{Mesh, Primitive, Vertex}; +use egui::{Color32, TextureId}; +use tiny_skia::{Pixmap, PremultipliedColorU8}; + +use crate::texture::CpuTexture; +use crate::TextureStore; + +#[derive(Debug, Default, Clone, Copy)] +pub struct PaintStats { + pub mesh_count: usize, + pub callback_count: usize, + pub triangle_count: usize, + pub candidate_pixel_count: u64, + pub shaded_pixel_count: u64, + pub blended_pixel_count: u64, + pub texture_sample_count: u64, + pub fast_rect_count: usize, + pub fast_rect_pixels: u64, + pub generic_triangle_count: usize, + pub fast_rect_miss_missing_texture: usize, + pub fast_rect_miss_not_quad: usize, + pub fast_rect_miss_non_uniform_color: usize, + pub fast_rect_miss_non_rect_geometry: usize, + pub fast_rect_miss_empty_clip: usize, +} + +pub fn paint_primitives(pixmap: &mut Pixmap, primitives: &[egui::ClippedPrimitive], textures: &TextureStore) -> PaintStats { + let mut stats = PaintStats::default(); + + for primitive in primitives { + match &primitive.primitive { + Primitive::Mesh(mesh) => { + stats.mesh_count += 1; + paint_mesh(pixmap, primitive.clip_rect, mesh, textures, &mut stats); + } + Primitive::Callback(_) => { + stats.callback_count += 1; + } + } + } + + stats +} + +fn paint_mesh(pixmap: &mut Pixmap, clip_rect: egui::Rect, mesh: &Mesh, textures: &TextureStore, stats: &mut PaintStats) { + match detect_fast_textured_rect(mesh, clip_rect, textures) { + FastRectMatch::Matched { texture, rect } => { + paint_textured_rect(pixmap, texture, rect, stats); + return; + } + FastRectMatch::Missed(reason) => stats.record_fast_rect_miss(reason), + } + + for indices in mesh.indices.chunks_exact(3) { + let a = mesh.vertices[indices[0] as usize]; + let b = mesh.vertices[indices[1] as usize]; + let c = mesh.vertices[indices[2] as usize]; + paint_triangle(pixmap, clip_rect, mesh.texture_id, textures, a, b, c, stats); + } +} + +impl PaintStats { + fn record_fast_rect_miss(&mut self, reason: FastRectMissReason) { + match reason { + FastRectMissReason::MissingTexture => self.fast_rect_miss_missing_texture += 1, + FastRectMissReason::NotQuad => self.fast_rect_miss_not_quad += 1, + FastRectMissReason::NonUniformColor => self.fast_rect_miss_non_uniform_color += 1, + FastRectMissReason::NonRectGeometry => self.fast_rect_miss_non_rect_geometry += 1, + FastRectMissReason::EmptyClip => self.fast_rect_miss_empty_clip += 1, + } + } +} + +fn paint_triangle( + pixmap: &mut Pixmap, + clip_rect: egui::Rect, + texture_id: TextureId, + textures: &TextureStore, + a: Vertex, + b: Vertex, + c: Vertex, + stats: &mut PaintStats, +) { + stats.triangle_count += 1; + stats.generic_triangle_count += 1; + + let width = pixmap.width() as i32; + let height = pixmap.height() as i32; + + let min_x = a.pos.x.min(b.pos.x).min(c.pos.x).floor().max(clip_rect.min.x.floor()).max(0.0) as i32; + let max_x = a.pos.x.max(b.pos.x).max(c.pos.x).ceil().min(clip_rect.max.x.ceil()).min(width as f32) as i32; + let min_y = a.pos.y.min(b.pos.y).min(c.pos.y).floor().max(clip_rect.min.y.floor()).max(0.0) as i32; + let max_y = a.pos.y.max(b.pos.y).max(c.pos.y).ceil().min(clip_rect.max.y.ceil()).min(height as f32) as i32; + + if min_x >= max_x || min_y >= max_y { + return; + } + + stats.candidate_pixel_count += ((max_x - min_x) as u64) * ((max_y - min_y) as u64); + + let area = edge(a.pos, b.pos, c.pos); + if area.abs() <= f32::EPSILON { + return; + } + + for y in min_y..max_y { + for x in min_x..max_x { + let p = egui::pos2(x as f32 + 0.5, y as f32 + 0.5); + let w0 = edge(b.pos, c.pos, p) / area; + let w1 = edge(c.pos, a.pos, p) / area; + let w2 = edge(a.pos, b.pos, p) / area; + + let epsilon = -0.0001; + if w0 < epsilon || w1 < epsilon || w2 < epsilon { + continue; + } + + stats.shaded_pixel_count += 1; + let vertex_color = interpolate_color(a.color, b.color, c.color, w0, w1, w2); + let texture_color = textures + .get(texture_id) + .map(|texture| { + stats.texture_sample_count += 1; + let u = a.uv.x * w0 + b.uv.x * w1 + c.uv.x * w2; + let v = a.uv.y * w0 + b.uv.y * w1 + c.uv.y * w2; + texture.sample(u, v) + }) + .unwrap_or(Color32::WHITE); + let source = multiply_color(vertex_color, texture_color); + + if blend_pixel(pixmap, x as u32, y as u32, source) { + stats.blended_pixel_count += 1; + } + } + } +} + +fn edge(a: egui::Pos2, b: egui::Pos2, c: egui::Pos2) -> f32 { + (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x) +} + +fn interpolate_color(a: Color32, b: Color32, c: Color32, w0: f32, w1: f32, w2: f32) -> Color32 { + Color32::from_rgba_premultiplied( + interpolate_channel(a.r(), b.r(), c.r(), w0, w1, w2), + interpolate_channel(a.g(), b.g(), c.g(), w0, w1, w2), + interpolate_channel(a.b(), b.b(), c.b(), w0, w1, w2), + interpolate_channel(a.a(), b.a(), c.a(), w0, w1, w2), + ) +} + +fn interpolate_channel(a: u8, b: u8, c: u8, w0: f32, w1: f32, w2: f32) -> u8 { + ((a as f32 * w0 + b as f32 * w1 + c as f32 * w2).round()).clamp(0.0, 255.0) as u8 +} + +fn multiply_color(a: Color32, b: Color32) -> Color32 { + Color32::from_rgba_premultiplied( + multiply_channel(a.r(), b.r()), + multiply_channel(a.g(), b.g()), + multiply_channel(a.b(), b.b()), + multiply_channel(a.a(), b.a()), + ) +} + +fn multiply_channel(a: u8, b: u8) -> u8 { + ((a as u16 * b as u16 + 127) / 255) as u8 +} + +fn blend_pixel(pixmap: &mut Pixmap, x: u32, y: u32, source: Color32) -> bool { + if source.a() == 0 { + return false; + } + + let index = y as usize * pixmap.width() as usize + x as usize; + if source.a() == 255 { + pixmap.pixels_mut()[index] = PremultipliedColorU8::from_rgba(source.r(), source.g(), source.b(), 255).expect("opaque color must be valid premultiplied rgba"); + return true; + } + + let destination = pixmap.pixels_mut()[index]; + let source_alpha = source.a() as u16; + let inverse_alpha = 255 - source_alpha; + + let red = source.r() as u16 + destination.red() as u16 * inverse_alpha / 255; + let green = source.g() as u16 + destination.green() as u16 * inverse_alpha / 255; + let blue = source.b() as u16 + destination.blue() as u16 * inverse_alpha / 255; + let alpha = source_alpha + destination.alpha() as u16 * inverse_alpha / 255; + + pixmap.pixels_mut()[index] = PremultipliedColorU8::from_rgba(red.min(255) as u8, green.min(255) as u8, blue.min(255) as u8, alpha.min(255) as u8) + .expect("blended color must be valid premultiplied rgba"); + true +} + +#[derive(Clone, Copy)] +struct TexturedRect { + pos: egui::Rect, + uv: egui::Rect, + color: Color32, +} + +enum FastRectMatch<'a> { + Matched { texture: &'a CpuTexture, rect: TexturedRect }, + Missed(FastRectMissReason), +} + +#[derive(Clone, Copy)] +enum FastRectMissReason { + MissingTexture, + NotQuad, + NonUniformColor, + NonRectGeometry, + EmptyClip, +} + +fn detect_fast_textured_rect<'a>(mesh: &Mesh, clip_rect: egui::Rect, textures: &'a TextureStore) -> FastRectMatch<'a> { + let Some(texture) = textures.get(mesh.texture_id) else { + return FastRectMatch::Missed(FastRectMissReason::MissingTexture); + }; + + if mesh.vertices.len() != 4 || mesh.indices.len() != 6 { + return FastRectMatch::Missed(FastRectMissReason::NotQuad); + } + + let color = mesh.vertices[0].color; + if color.a() != 255 || !mesh.vertices.iter().all(|vertex| vertex.color == color) { + return FastRectMatch::Missed(FastRectMissReason::NonUniformColor); + } + + let min_x = mesh.vertices.iter().map(|vertex| vertex.pos.x).fold(f32::INFINITY, f32::min); + let max_x = mesh.vertices.iter().map(|vertex| vertex.pos.x).fold(f32::NEG_INFINITY, f32::max); + let min_y = mesh.vertices.iter().map(|vertex| vertex.pos.y).fold(f32::INFINITY, f32::min); + let max_y = mesh.vertices.iter().map(|vertex| vertex.pos.y).fold(f32::NEG_INFINITY, f32::max); + let min_u = mesh.vertices.iter().map(|vertex| vertex.uv.x).fold(f32::INFINITY, f32::min); + let max_u = mesh.vertices.iter().map(|vertex| vertex.uv.x).fold(f32::NEG_INFINITY, f32::max); + let min_v = mesh.vertices.iter().map(|vertex| vertex.uv.y).fold(f32::INFINITY, f32::min); + let max_v = mesh.vertices.iter().map(|vertex| vertex.uv.y).fold(f32::NEG_INFINITY, f32::max); + + if min_x >= max_x || min_y >= max_y || min_u > max_u || min_v > max_v { + return FastRectMatch::Missed(FastRectMissReason::NonRectGeometry); + } + + for vertex in &mesh.vertices { + let on_x_edge = approx_eq(vertex.pos.x, min_x) || approx_eq(vertex.pos.x, max_x); + let on_y_edge = approx_eq(vertex.pos.y, min_y) || approx_eq(vertex.pos.y, max_y); + let on_u_edge = approx_eq(vertex.uv.x, min_u) || approx_eq(vertex.uv.x, max_u); + let on_v_edge = approx_eq(vertex.uv.y, min_v) || approx_eq(vertex.uv.y, max_v); + if !(on_x_edge && on_y_edge && on_u_edge && on_v_edge) { + return FastRectMatch::Missed(FastRectMissReason::NonRectGeometry); + } + } + + let unclipped_pos = egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y)); + let pos = unclipped_pos.intersect(clip_rect); + if !pos.is_positive() { + return FastRectMatch::Missed(FastRectMissReason::EmptyClip); + } + + // `pos` may be a strict sub-rect of `unclipped_pos` when the clip rect cuts through it, so the + // uv rect must shrink by the same fraction - otherwise `paint_textured_rect` (which maps its full + // uv range across whatever `pos` ends up being) would sample the *entire* texture into just the + // visible sliver, squishing it instead of cropping it. + let t_min_x = (pos.min.x - unclipped_pos.min.x) / unclipped_pos.width(); + let t_max_x = (pos.max.x - unclipped_pos.min.x) / unclipped_pos.width(); + let t_min_y = (pos.min.y - unclipped_pos.min.y) / unclipped_pos.height(); + let t_max_y = (pos.max.y - unclipped_pos.min.y) / unclipped_pos.height(); + let uv = egui::Rect::from_min_max( + egui::pos2(lerp(min_u, max_u, t_min_x), lerp(min_v, max_v, t_min_y)), + egui::pos2(lerp(min_u, max_u, t_max_x), lerp(min_v, max_v, t_max_y)), + ); + + FastRectMatch::Matched { + texture, + rect: TexturedRect { pos, uv, color }, + } +} + +fn paint_textured_rect(pixmap: &mut Pixmap, texture: &CpuTexture, rect: TexturedRect, stats: &mut PaintStats) { + let min_x = rect.pos.min.x.floor().max(0.0) as i32; + let max_x = rect.pos.max.x.ceil().min(pixmap.width() as f32) as i32; + let min_y = rect.pos.min.y.floor().max(0.0) as i32; + let max_y = rect.pos.max.y.ceil().min(pixmap.height() as f32) as i32; + if min_x >= max_x || min_y >= max_y { + return; + } + + stats.fast_rect_count += 1; + stats.triangle_count += 2; + + let pos_width = rect.pos.width().max(f32::EPSILON); + let pos_height = rect.pos.height().max(f32::EPSILON); + for y in min_y..max_y { + let ty = ((y as f32 + 0.5) - rect.pos.min.y) / pos_height; + let v = lerp(rect.uv.min.y, rect.uv.max.y, ty); + for x in min_x..max_x { + stats.fast_rect_pixels += 1; + stats.shaded_pixel_count += 1; + stats.texture_sample_count += 1; + + let tx = ((x as f32 + 0.5) - rect.pos.min.x) / pos_width; + let u = lerp(rect.uv.min.x, rect.uv.max.x, tx); + let texture_color = texture.sample(u, v); + let source = multiply_color(rect.color, texture_color); + if blend_pixel(pixmap, x as u32, y as u32, source) { + stats.blended_pixel_count += 1; + } + } + } +} + +fn lerp(min: f32, max: f32, t: f32) -> f32 { + min + (max - min) * t +} + +fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() <= 0.001 +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::epaint::ImageDelta; + use egui::{TextureOptions, TexturesDelta}; + use tiny_skia::Color; + + fn test_mesh(texture_id: TextureId, color: Color32) -> Mesh { + Mesh { + indices: vec![0, 1, 2, 0, 2, 3], + vertices: vec![ + Vertex { + pos: egui::pos2(1.0, 1.0), + uv: egui::pos2(0.0, 0.0), + color, + }, + Vertex { + pos: egui::pos2(3.0, 1.0), + uv: egui::pos2(1.0, 0.0), + color, + }, + Vertex { + pos: egui::pos2(3.0, 3.0), + uv: egui::pos2(1.0, 1.0), + color, + }, + Vertex { + pos: egui::pos2(1.0, 3.0), + uv: egui::pos2(0.0, 1.0), + color, + }, + ], + texture_id, + } + } + + fn texture_store(pixels: Vec) -> TextureStore { + let texture_id = TextureId::Managed(0); + let image = egui::ColorImage::new([2, 2], pixels); + let delta = TexturesDelta { + set: vec![(texture_id, ImageDelta::full(image, TextureOptions::NEAREST))], + free: Vec::new(), + }; + let mut textures = TextureStore::default(); + textures.apply_delta(delta).unwrap(); + textures + } + + fn transparent_pixmap() -> Pixmap { + let mut pixmap = Pixmap::new(4, 4).unwrap(); + pixmap.fill(Color::from_rgba8(0, 0, 0, 0)); + pixmap + } + + #[test] + fn blend_pixel_skips_transparent_and_overwrites_opaque() { + let mut pixmap = transparent_pixmap(); + + assert!(!blend_pixel(&mut pixmap, 1, 1, Color32::TRANSPARENT)); + assert_eq!(pixmap.pixels()[5].alpha(), 0); + + assert!(blend_pixel(&mut pixmap, 1, 1, Color32::RED)); + let pixel = pixmap.pixels()[5]; + assert_eq!((pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()), (255, 0, 0, 255)); + } + + #[test] + fn detects_strict_axis_aligned_textured_rect() { + let mesh = test_mesh(TextureId::Managed(0), Color32::WHITE); + let textures = texture_store(vec![Color32::RED, Color32::TRANSPARENT, Color32::GREEN, Color32::BLUE]); + let FastRectMatch::Matched { rect, .. } = detect_fast_textured_rect(&mesh, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 4.0)), &textures) else { + panic!("expected fast rect match"); + }; + + assert_eq!(rect.pos.min, egui::pos2(1.0, 1.0)); + assert_eq!(rect.pos.max, egui::pos2(3.0, 3.0)); + assert_eq!(rect.uv.min, egui::pos2(0.0, 0.0)); + assert_eq!(rect.uv.max, egui::pos2(1.0, 1.0)); + } + + #[test] + fn rejects_non_uniform_vertex_color_for_fast_rect() { + let mut mesh = test_mesh(TextureId::Managed(0), Color32::WHITE); + mesh.vertices[0].color = Color32::RED; + + let textures = texture_store(vec![Color32::RED, Color32::TRANSPARENT, Color32::GREEN, Color32::BLUE]); + assert!(matches!( + detect_fast_textured_rect(&mesh, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 4.0)), &textures), + FastRectMatch::Missed(FastRectMissReason::NonUniformColor) + )); + } + + #[test] + fn binary_alpha_fast_rect_matches_generic_triangle_output() { + let texture_id = TextureId::Managed(0); + let textures = texture_store(vec![Color32::RED, Color32::TRANSPARENT, Color32::GREEN, Color32::BLUE]); + let mesh = test_mesh(texture_id, Color32::WHITE); + let clip_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 4.0)); + + let mut fast = transparent_pixmap(); + let fast_stats = paint_primitives( + &mut fast, + &[egui::ClippedPrimitive { + clip_rect, + primitive: Primitive::Mesh(mesh.clone()), + }], + &textures, + ); + + let mut generic = transparent_pixmap(); + let mut generic_stats = PaintStats::default(); + for indices in mesh.indices.chunks_exact(3) { + paint_triangle( + &mut generic, + clip_rect, + mesh.texture_id, + &textures, + mesh.vertices[indices[0] as usize], + mesh.vertices[indices[1] as usize], + mesh.vertices[indices[2] as usize], + &mut generic_stats, + ); + } + + assert_eq!(fast_stats.fast_rect_count, 1); + assert_eq!(fast_stats.generic_triangle_count, 0); + assert_eq!(fast.pixels(), generic.pixels()); + } + + #[test] + fn fast_rect_crops_instead_of_squishing_under_partial_clip() { + let texture_id = TextureId::Managed(0); + let textures = texture_store(vec![Color32::RED, Color32::from_rgba_unmultiplied(0, 255, 0, 128), Color32::GREEN, Color32::BLUE]); + let mesh = test_mesh(texture_id, Color32::WHITE); + // The mesh's quad spans (1,1)-(3,3); clipping to (0,0)-(4,2) cuts it in half vertically, so + // only the top half of the destination rect is visible - the uv range sampled for that + // visible half must shrink to match, not stay the full 0..1 range. + let clip_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 2.0)); + + let mut fast = transparent_pixmap(); + let fast_stats = paint_primitives( + &mut fast, + &[egui::ClippedPrimitive { + clip_rect, + primitive: Primitive::Mesh(mesh.clone()), + }], + &textures, + ); + + let mut generic = transparent_pixmap(); + let mut generic_stats = PaintStats::default(); + for indices in mesh.indices.chunks_exact(3) { + paint_triangle( + &mut generic, + clip_rect, + mesh.texture_id, + &textures, + mesh.vertices[indices[0] as usize], + mesh.vertices[indices[1] as usize], + mesh.vertices[indices[2] as usize], + &mut generic_stats, + ); + } + + assert_eq!(fast_stats.fast_rect_count, 1); + assert_eq!(fast.pixels(), generic.pixels()); + } + + #[test] + fn mixed_alpha_texture_uses_fast_rect_with_blending() { + let texture_id = TextureId::Managed(0); + let textures = texture_store(vec![Color32::RED, Color32::from_rgba_unmultiplied(0, 255, 0, 128), Color32::GREEN, Color32::BLUE]); + let mesh = test_mesh(texture_id, Color32::WHITE); + let clip_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 4.0)); + let mut pixmap = transparent_pixmap(); + + let stats = paint_primitives( + &mut pixmap, + &[egui::ClippedPrimitive { + clip_rect, + primitive: Primitive::Mesh(mesh), + }], + &textures, + ); + + assert_eq!(stats.fast_rect_count, 1); + assert_eq!(stats.generic_triangle_count, 0); + assert!(stats.blended_pixel_count > 0); + } +} diff --git a/egui-tiny-skia/src/texture.rs b/egui-tiny-skia/src/texture.rs new file mode 100644 index 0000000..3c94add --- /dev/null +++ b/egui-tiny-skia/src/texture.rs @@ -0,0 +1,245 @@ +use std::collections::HashMap; +use std::fmt; + +use egui::{Color32, ImageData, TextureId, TexturesDelta}; + +#[derive(Debug, Default)] +pub struct TextureStore { + textures: HashMap, +} + +#[derive(Debug, Clone)] +pub struct CpuTexture { + width: usize, + height: usize, + pixels: Vec, + alpha_profile: AlphaProfile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlphaKind { + FullyOpaque, + BinaryAlpha, + MixedAlpha, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AlphaProfile { + kind: AlphaKind, + transparent_count: usize, + opaque_count: usize, + mixed_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextureStoreError { + InvalidPixelCount { + width: usize, + height: usize, + pixels: usize, + }, + MissingTexture(TextureId), + PartialUpdateOutOfBounds { + texture: TextureId, + x: usize, + y: usize, + width: usize, + height: usize, + texture_width: usize, + texture_height: usize, + }, +} + +impl fmt::Display for TextureStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPixelCount { width, height, pixels } => write!(f, "texture has dimensions {width}x{height}, but {pixels} pixels"), + Self::MissingTexture(texture_id) => { + write!(f, "partial update for missing texture {texture_id:?}") + } + Self::PartialUpdateOutOfBounds { + texture, + x, + y, + width, + height, + texture_width, + texture_height, + } => write!( + f, + "partial update for {texture:?} at {x},{y} with size {width}x{height} exceeds texture size {texture_width}x{texture_height}" + ), + } + } +} + +impl std::error::Error for TextureStoreError {} + +impl TextureStore { + pub fn apply_delta(&mut self, delta: TexturesDelta) -> Result<(), TextureStoreError> { + for (texture_id, image_delta) in delta.set { + let image = CpuTexture::from_image_data(&image_delta.image)?; + + if let Some([x, y]) = image_delta.pos { + let texture = self.textures.get_mut(&texture_id).ok_or(TextureStoreError::MissingTexture(texture_id))?; + texture.update_region(texture_id, x, y, &image)?; + } else { + self.textures.insert(texture_id, image); + } + } + + for texture_id in delta.free { + self.textures.remove(&texture_id); + } + + Ok(()) + } + + pub fn get(&self, texture_id: TextureId) -> Option<&CpuTexture> { + self.textures.get(&texture_id) + } +} + +impl CpuTexture { + fn from_image_data(image_data: &ImageData) -> Result { + match image_data { + ImageData::Color(image) => { + let [width, height] = image.size; + Self::new(width, height, image.pixels.clone()) + } + } + } + + fn new(width: usize, height: usize, pixels: Vec) -> Result { + if pixels.len() != width * height { + return Err(TextureStoreError::InvalidPixelCount { + width, + height, + pixels: pixels.len(), + }); + } + + let alpha_profile = classify_alpha(&pixels); + Ok(Self { width, height, pixels, alpha_profile }) + } + + fn update_region(&mut self, texture_id: TextureId, x: usize, y: usize, update: &CpuTexture) -> Result<(), TextureStoreError> { + if x + update.width > self.width || y + update.height > self.height { + return Err(TextureStoreError::PartialUpdateOutOfBounds { + texture: texture_id, + x, + y, + width: update.width, + height: update.height, + texture_width: self.width, + texture_height: self.height, + }); + } + + for row in 0..update.height { + let destination_start = (y + row) * self.width + x; + let source_start = row * update.width; + let destination = &mut self.pixels[destination_start..destination_start + update.width]; + let source = &update.pixels[source_start..source_start + update.width]; + destination.copy_from_slice(source); + } + + self.alpha_profile = classify_alpha(&self.pixels); + Ok(()) + } + + pub fn alpha_kind(&self) -> AlphaKind { + self.alpha_profile.kind + } + + pub fn alpha_profile(&self) -> AlphaProfile { + self.alpha_profile + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + pub fn sample_pixel(&self, x: usize, y: usize) -> Color32 { + self.pixels[y.min(self.height.saturating_sub(1)) * self.width + x.min(self.width.saturating_sub(1))] + } + + pub fn sample(&self, u: f32, v: f32) -> Color32 { + let x = (u.clamp(0.0, 1.0) * (self.width.saturating_sub(1) as f32)).round() as usize; + let y = (v.clamp(0.0, 1.0) * (self.height.saturating_sub(1) as f32)).round() as usize; + self.sample_pixel(x, y) + } +} + +fn classify_alpha(pixels: &[Color32]) -> AlphaProfile { + let mut transparent_count = 0; + let mut opaque_count = 0; + let mut mixed_count = 0; + + for pixel in pixels { + match pixel.a() { + 0 => transparent_count += 1, + 255 => opaque_count += 1, + _ => mixed_count += 1, + } + } + + let kind = if mixed_count > 0 { + AlphaKind::MixedAlpha + } else if transparent_count > 0 { + AlphaKind::BinaryAlpha + } else { + AlphaKind::FullyOpaque + }; + + AlphaProfile { + kind, + transparent_count, + opaque_count, + mixed_count, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn color_image(pixels: Vec) -> egui::ImageData { + egui::ImageData::Color(egui::ColorImage::new([pixels.len(), 1], pixels).into()) + } + + #[test] + fn classifies_fully_opaque_textures() { + let texture = CpuTexture::from_image_data(&color_image(vec![Color32::RED, Color32::GREEN])).unwrap(); + + assert_eq!(texture.alpha_kind(), AlphaKind::FullyOpaque); + } + + #[test] + fn classifies_binary_alpha_textures() { + let texture = CpuTexture::from_image_data(&color_image(vec![Color32::TRANSPARENT, Color32::RED])).unwrap(); + + assert_eq!(texture.alpha_kind(), AlphaKind::BinaryAlpha); + } + + #[test] + fn classifies_mixed_alpha_textures() { + let texture = CpuTexture::from_image_data(&color_image(vec![Color32::from_rgba_unmultiplied(10, 20, 30, 128)])).unwrap(); + + assert_eq!(texture.alpha_kind(), AlphaKind::MixedAlpha); + } + + #[test] + fn partial_update_recomputes_alpha_classification() { + let mut texture = CpuTexture::from_image_data(&color_image(vec![Color32::RED, Color32::GREEN])).unwrap(); + let update = CpuTexture::from_image_data(&color_image(vec![Color32::TRANSPARENT])).unwrap(); + + texture.update_region(TextureId::Managed(0), 1, 0, &update).unwrap(); + + assert_eq!(texture.alpha_kind(), AlphaKind::BinaryAlpha); + } +} diff --git a/egui-tiny-skia/tests/render.rs b/egui-tiny-skia/tests/render.rs new file mode 100644 index 0000000..bdf540c --- /dev/null +++ b/egui-tiny-skia/tests/render.rs @@ -0,0 +1,28 @@ +use egui_tiny_skia::TinySkiaBackend; + +#[test] +fn renders_window_with_label_to_non_magenta_pixels() { + let mut backend = TinySkiaBackend::new(800, 600); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + ..Default::default() + }; + + let output = backend.run_frame(input, |ctx| { + egui::Window::new("test") + .fixed_pos(egui::pos2(20.0, 20.0)) + .fixed_size(egui::vec2(200.0, 120.0)) + .show(ctx, |ui| { + ui.label("hello"); + }); + }); + + let pixmap = backend.paint(output); + let has_non_magenta = pixmap.pixels().iter().enumerate().any(|(index, pixel)| { + let x = index % 800; + let y = index / 800; + (0..400).contains(&x) && (0..300).contains(&y) && !(pixel.red() == 255 && pixel.green() == 0 && pixel.blue() == 255 && pixel.alpha() == 255) + }); + + assert!(has_non_magenta); +} diff --git a/openzt-detour/src/generated.rs b/openzt-detour/src/generated.rs index a8d23e9..c13c3b6 100644 --- a/openzt-detour/src/generated.rs +++ b/openzt-detour/src/generated.rs @@ -3,6 +3,7 @@ #![allow(clippy::type_complexity)] +use std::ffi::c_void; use std::marker::PhantomData; use crate::FunctionDef; @@ -730,16 +731,16 @@ pub mod bftile { pub mod bfuimgr { use super::*; - pub const GET_ELEMENT_0: FunctionDef u32> = FunctionDef{address: 0x0040157d, function_type: PhantomData}; + pub const GET_ELEMENT_0: FunctionDef *const u32> = FunctionDef{address: 0x0040157d, function_type: PhantomData}; pub const SET_CONTROL_FORE_COLOR: FunctionDef = FunctionDef{address: 0x0040ee08, function_type: PhantomData}; - pub const SET_CURSOR: FunctionDef = FunctionDef{address: 0x00418e81, function_type: PhantomData}; - pub const HIDE_BUSY_CURSOR: FunctionDef = FunctionDef{address: 0x00418f2f, function_type: PhantomData}; - pub const SHOW_BUSY_CURSOR: FunctionDef = FunctionDef{address: 0x00418f43, function_type: PhantomData}; + pub const SET_CURSOR: FunctionDef = FunctionDef{address: 0x00418e81, function_type: PhantomData}; + pub const HIDE_BUSY_CURSOR: FunctionDef = FunctionDef{address: 0x00418f2f, function_type: PhantomData}; + pub const SHOW_BUSY_CURSOR: FunctionDef = FunctionDef{address: 0x00418f43, function_type: PhantomData}; pub const DRAW: FunctionDef u32> = FunctionDef{address: 0x004193d8, function_type: PhantomData}; pub const UPDATE: FunctionDef = FunctionDef{address: 0x0041a16b, function_type: PhantomData}; pub const PREPARE_TO_HIDE_ELEMENT: FunctionDef = FunctionDef{address: 0x0041ac35, function_type: PhantomData}; pub const REMOVE_TIMER_ELEMENT: FunctionDef u32> = FunctionDef{address: 0x0041ad6a, function_type: PhantomData}; - pub const DISPLAY_HELP_0: FunctionDef = FunctionDef{address: 0x0041b100, function_type: PhantomData}; + pub const DISPLAY_HELP_0: FunctionDef = FunctionDef{address: 0x0041b100, function_type: PhantomData}; pub const HANDLE_MESSAGES: FunctionDef i32> = FunctionDef{address: 0x00441d7b, function_type: PhantomData}; pub const GET_ELEMENT_1: FunctionDef u32> = FunctionDef{address: 0x0044232b, function_type: PhantomData}; pub const GET_MAX_TOOLTIP_WIDTH: FunctionDef u32> = FunctionDef{address: 0x00442d19, function_type: PhantomData}; @@ -752,17 +753,17 @@ pub mod bfuimgr { pub const DISPLAY_MESSAGE_1: FunctionDef = FunctionDef{address: 0x0049cec0, function_type: PhantomData}; pub const SHOW_LAST_DIALOG: FunctionDef = FunctionDef{address: 0x004a09cc, function_type: PhantomData}; pub const HIDE_PERSISTENT_TEXT: FunctionDef = FunctionDef{address: 0x004b28df, function_type: PhantomData}; - pub const DESTROY_CURSORS: FunctionDef = FunctionDef{address: 0x004b2caa, function_type: PhantomData}; + pub const DESTROY_CURSORS: FunctionDef = FunctionDef{address: 0x004b2caa, function_type: PhantomData}; pub const CLEAR_MESSAGES: FunctionDef = FunctionDef{address: 0x004c6d10, function_type: PhantomData}; pub const SELECT_ELEMENT: FunctionDef = FunctionDef{address: 0x004c7794, function_type: PhantomData}; - pub const INIT_CURSORS: FunctionDef = FunctionDef{address: 0x004d4118, function_type: PhantomData}; + pub const INIT_CURSORS: FunctionDef = FunctionDef{address: 0x004d4118, function_type: PhantomData}; pub const RESIZE: FunctionDef u32> = FunctionDef{address: 0x004d41f4, function_type: PhantomData}; pub const RELOAD: FunctionDef u32> = FunctionDef{address: 0x004d594b, function_type: PhantomData}; - pub const RESET_ELEMENT_CURSOR: FunctionDef = FunctionDef{address: 0x004d5bea, function_type: PhantomData}; + pub const RESET_ELEMENT_CURSOR: FunctionDef = FunctionDef{address: 0x004d5bea, function_type: PhantomData}; pub const DISABLE_ELEMENT: FunctionDef = FunctionDef{address: 0x004df425, function_type: PhantomData}; pub const REMOVE_ELEMENT: FunctionDef u32> = FunctionDef{address: 0x004e0965, function_type: PhantomData}; pub const DELETE_ELEMENT: FunctionDef u32> = FunctionDef{address: 0x004e0e21, function_type: PhantomData}; - pub const DISPLAY_HELP_1: FunctionDef = FunctionDef{address: 0x004fb613, function_type: PhantomData}; + pub const DISPLAY_HELP_1: FunctionDef = FunctionDef{address: 0x004fb613, function_type: PhantomData}; pub const CONFIRM_DIALOG_0: FunctionDef u32> = FunctionDef{address: 0x004ff63c, function_type: PhantomData}; pub const CONFIRM_DIALOG_1: FunctionDef u32> = FunctionDef{address: 0x004fff2c, function_type: PhantomData}; pub const DELETE_ALL_ELEMENTS_0: FunctionDef u32> = FunctionDef{address: 0x0050214c, function_type: PhantomData}; @@ -859,7 +860,7 @@ pub mod bfwindow { pub const STYLE_POPUP: FunctionDef = FunctionDef{address: 0x004d641d, function_type: PhantomData}; pub const STYLE_FIXED: FunctionDef = FunctionDef{address: 0x0052f150, function_type: PhantomData}; pub const POINTER: FunctionDef bool> = FunctionDef{address: 0x005336d5, function_type: PhantomData}; - pub const ATTACH: FunctionDef bool> = FunctionDef{address: 0x00536486, function_type: PhantomData}; + pub const ATTACH: FunctionDef bool> = FunctionDef{address: 0x00536486, function_type: PhantomData}; } // BFWindowClass class functions @@ -1025,7 +1026,7 @@ pub mod gxpalettemap { pub mod gxvideomanager { use super::*; - pub const FLIP: FunctionDef bool> = FunctionDef{address: 0x004013bf, function_type: PhantomData}; + pub const FLIP: FunctionDef bool> = FunctionDef{address: 0x004013bf, function_type: PhantomData}; pub const CREATE_SURFACE: FunctionDef u32> = FunctionDef{address: 0x0041ba74, function_type: PhantomData}; pub const FLIP_TO_GDI: FunctionDef = FunctionDef{address: 0x004cc682, function_type: PhantomData}; pub const SWITCH_MODE: FunctionDef = FunctionDef{address: 0x004d601e, function_type: PhantomData}; @@ -2242,10 +2243,10 @@ pub mod ztmapview { pub const DRAW: FunctionDef = FunctionDef{address: 0x00419729, function_type: PhantomData}; pub const UPDATE: FunctionDef = FunctionDef{address: 0x00419eb2, function_type: PhantomData}; pub const PREPARE_TO_DELETE_ENTITY: FunctionDef = FunctionDef{address: 0x0041e145, function_type: PhantomData}; - pub const USE_CURSOR_0: FunctionDef = FunctionDef{address: 0x0043231a, function_type: PhantomData}; + pub const USE_CURSOR: FunctionDef = FunctionDef{address: 0x0043231a, function_type: PhantomData}; pub const RENDER_0: FunctionDef = FunctionDef{address: 0x00432bd7, function_type: PhantomData}; pub const SHOW_CURRENT_COST: FunctionDef = FunctionDef{address: 0x00433918, function_type: PhantomData}; - pub const CHECK_MOUSE_OVER_ENTITY: FunctionDef = FunctionDef{address: 0x00433b34, function_type: PhantomData}; + pub const CHECK_MOUSE_OVER_ENTITY: FunctionDef = FunctionDef{address: 0x00433b34, function_type: PhantomData}; pub const USE_CURSOR_1: FunctionDef = FunctionDef{address: 0x00443d97, function_type: PhantomData}; pub const HIGHLIGHT_MAP: FunctionDef = FunctionDef{address: 0x00443daf, function_type: PhantomData}; pub const CANCEL_ENTITY_MOVE: FunctionDef = FunctionDef{address: 0x00443df7, function_type: PhantomData}; @@ -2262,7 +2263,7 @@ pub mod ztmapview { pub const SET_MAP_VIEW: FunctionDef = FunctionDef{address: 0x004b0384, function_type: PhantomData}; pub const SET_MAP_SELECTION: FunctionDef = FunctionDef{address: 0x004b05ab, function_type: PhantomData}; pub const ROTATE_MAP: FunctionDef = FunctionDef{address: 0x004b06be, function_type: PhantomData}; - pub const ZOOM_MAP: FunctionDef = FunctionDef{address: 0x004b072d, function_type: PhantomData}; + pub const ZOOM_MAP: FunctionDef = FunctionDef{address: 0x004b072d, function_type: PhantomData}; pub const SET_SELECTED_ENTITY_0: FunctionDef = FunctionDef{address: 0x004b2322, function_type: PhantomData}; pub const CHECK_ZOOM_OPTIONS: FunctionDef = FunctionDef{address: 0x004b2868, function_type: PhantomData}; pub const DESTROY_CURSORS: FunctionDef = FunctionDef{address: 0x004b2c84, function_type: PhantomData}; @@ -2299,7 +2300,7 @@ pub mod ztmapview { pub const SET_MAP: FunctionDef = FunctionDef{address: 0x00519ba0, function_type: PhantomData}; pub const CONSTRUCTOR: FunctionDef u32> = FunctionDef{address: 0x0051f80c, function_type: PhantomData}; pub const CREATE: FunctionDef = FunctionDef{address: 0x005205bc, function_type: PhantomData}; - pub const TAKE_SNAPSHOT: FunctionDef = FunctionDef{address: 0x00532c75, function_type: PhantomData}; + pub const TAKE_SNAPSHOT: FunctionDef = FunctionDef{address: 0x00532c75, function_type: PhantomData}; pub const LOAD_STATE: FunctionDef = FunctionDef{address: 0x00592907, function_type: PhantomData}; pub const SET_VIEW_CENTER: FunctionDef = FunctionDef{address: 0x0059b90c, function_type: PhantomData}; pub const ADD_UNDO_ACTION: FunctionDef = FunctionDef{address: 0x005b32de, function_type: PhantomData}; @@ -2919,7 +2920,7 @@ pub mod ztui_gamescrn { pub mod ztui_general { use super::*; - pub const GET_MAPVIEW: FunctionDef u32> = FunctionDef{address: 0x004017e5, function_type: PhantomData}; + pub const GET_MAPVIEW: FunctionDef *const u32> = FunctionDef{address: 0x004017e5, function_type: PhantomData}; pub const UPDATE_DISPLAY_LISTS: FunctionDef = FunctionDef{address: 0x0040d37f, function_type: PhantomData}; pub const GET_SELECTED_ENTITY: FunctionDef u32> = FunctionDef{address: 0x00410f84, function_type: PhantomData}; pub const HIDE_MISC_PANELS: FunctionDef = FunctionDef{address: 0x00443af8, function_type: PhantomData}; @@ -2927,7 +2928,7 @@ pub mod ztui_general { pub const HIDE_PLAQUES: FunctionDef = FunctionDef{address: 0x0044410c, function_type: PhantomData}; pub const REMOVE_SELECTED_ENTITY: FunctionDef = FunctionDef{address: 0x0048d052, function_type: PhantomData}; pub const HIDE_INFO_PANELS: FunctionDef = FunctionDef{address: 0x004b2355, function_type: PhantomData}; - pub const ENTITY_TYPE_IS_DISPLAYED: FunctionDef bool> = FunctionDef{address: 0x004e8cc8, function_type: PhantomData}; + pub const ENTITY_TYPE_IS_DISPLAYED: FunctionDef bool> = FunctionDef{address: 0x004e8cc8, function_type: PhantomData}; pub const GET_INFO_IMAGE_NAME: FunctionDef u32> = FunctionDef{address: 0x004f85d2, function_type: PhantomData}; pub const INIT: FunctionDef = FunctionDef{address: 0x00519d24, function_type: PhantomData}; pub const HIDE_OBJECT_PANELS: FunctionDef = FunctionDef{address: 0x005b135b, function_type: PhantomData}; diff --git a/openzt-dll/Cargo.toml b/openzt-dll/Cargo.toml index 0f7b719..6074028 100644 --- a/openzt-dll/Cargo.toml +++ b/openzt-dll/Cargo.toml @@ -23,3 +23,5 @@ command-console = ["openzt/command-console"] integration-tests = ["openzt/integration-tests"] release = ["openzt/release"] vanilla-research-config = ["openzt/vanilla-research-config"] +egui-overlay = ["openzt/egui-overlay"] +debug-blit = ["openzt/debug-blit"] diff --git a/openzt.bat b/openzt.bat index 9227a3f..ddc32c5 100644 --- a/openzt.bat +++ b/openzt.bat @@ -19,6 +19,7 @@ IF "%~1"=="console" GOTO console IF "%~1"=="check" GOTO check IF "%~1"=="clippy" GOTO clippy IF "%~1"=="test" GOTO test +IF "%~1"=="test-egui-tiny-skia" GOTO test_egui_tiny_skia IF "%~1"=="integration-tests" GOTO integration_tests echo Error: Unknown subcommand "%~1" @@ -353,6 +354,25 @@ echo. echo Tests passed GOTO :EOF +REM ============================================================ +REM egui-tiny-skia Test Function +REM ============================================================ + +:test_egui_tiny_skia +echo Running cargo test on egui-tiny-skia... +cargo test --manifest-path egui-tiny-skia/Cargo.toml + +IF !errorlevel! NEQ 0 ( + echo. + echo egui-tiny-skia tests failed + pause + exit /b !errorlevel! +) + +echo. +echo egui-tiny-skia tests passed +GOTO :EOF + REM ============================================================ REM Help Function REM ============================================================ @@ -368,6 +388,7 @@ echo run Build the DLL and launch the game echo check Run cargo check on openzt crate echo clippy Run cargo clippy on openzt crate echo test Run cargo test on openzt crate +echo test-egui-tiny-skia Run cargo test on egui-tiny-skia crate echo integration-tests Run integration tests (builds release, launches game, displays results) echo docs Generate and open documentation echo console Open interactive Lua console or run oneshot command @@ -391,6 +412,7 @@ echo openzt.bat check Run cargo check on openzt echo openzt.bat clippy Run cargo clippy on openzt echo openzt.bat test Run cargo test on openzt echo openzt.bat test -- --nocapture Run cargo test, forwarding extra args to cargo +echo openzt.bat test-egui-tiny-skia Run cargo test on egui-tiny-skia echo openzt.bat integration-tests Run integration tests (builds release, displays results) echo openzt.bat docs Generate and open docs echo openzt.bat console Open interactive Lua console diff --git a/openzt/Cargo.toml b/openzt/Cargo.toml index f8904d1..c397d80 100644 --- a/openzt/Cargo.toml +++ b/openzt/Cargo.toml @@ -38,9 +38,12 @@ mlua = { version = "0.11.5", features = ["luajit52", "vendored", "send"] } encoding_rs = "0.8" ratatui = { version = "0.29", optional = true } crossterm = { version = "0.28", optional = true } +egui = { version = "0.33", default-features = false, features = ["default_fonts"], optional = true } +egui-tiny-skia = { path = "../egui-tiny-skia", version = "0.1.0", optional = true } +tiny-skia = { version = "0.11", optional = true } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62.2", features = ["Win32", "Win32_System_Console", "Win32_System_SystemServices", "Win32_System_Memory", "Win32_System_LibraryLoader", "Win32_Globalization", "Win32_UI_Input_KeyboardAndMouse"] } +windows = { version = "0.62.2", features = ["Win32", "Win32_Foundation", "Win32_System_Console", "Win32_System_SystemServices", "Win32_System_Memory", "Win32_System_LibraryLoader", "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } [dev-dependencies] @@ -60,3 +63,5 @@ integration-tests = [] command-console = [] tui = ["dep:ratatui", "dep:crossterm"] proptest = ["dep:proptest"] +egui-overlay = ["dep:egui", "dep:egui-tiny-skia", "dep:tiny-skia"] +debug-blit = ["egui-overlay"] diff --git a/openzt/src/cursors.rs b/openzt/src/cursors.rs new file mode 100644 index 0000000..05d4b26 --- /dev/null +++ b/openzt/src/cursors.rs @@ -0,0 +1,450 @@ +#[cfg(target_os = "windows")] +mod imp { + use std::ffi::c_void; + use std::sync::{Mutex, OnceLock}; + + use openzt_detour::generated::{ + bfuimgr::{ + DESTROY_CURSORS as BFUIMGR_DESTROY_CURSORS, HIDE_BUSY_CURSOR, INIT_CURSORS as BFUIMGR_INIT_CURSORS, + RESET_ELEMENT_CURSOR, SET_CURSOR, SHOW_BUSY_CURSOR, + }, + ztmapview::{DESTROY_CURSORS as ZTMAPVIEW_DESTROY_CURSORS, INIT_CURSORS as ZTMAPVIEW_INIT_CURSORS, USE_CURSOR}, + ztui_general::GET_MAPVIEW, + }; + use openzt_detour_macro::detour_mod; + use tracing::{error, info, warn}; + use windows::core::{PCSTR, PCWSTR}; + use windows::Win32::Foundation::{HINSTANCE, HWND}; + use windows::Win32::UI::WindowsAndMessaging::{ + DestroyCursor, FindWindowA, GetClassLongA, LoadCursorW, LoadImageA, SetClassLongA, SetCursor, GCL_HCURSOR, HCURSOR, IDC_ARROW, IDC_IBEAM, + IMAGE_CURSOR, + }; + + use crate::util::{get_from_memory, save_to_memory}; + + const GLOBAL_BFAPP: u32 = 0x00638148; + const GLOBAL_BFUIMGR: u32 = 0x00638de0; + const CURSOR_RESOURCE_OFFSET_ENABLED: u32 = 0x0063e9dc; + const CURSOR_RESOURCE_OFFSET: u32 = 0x0063e9e0; + const MAP_CURSOR_IDS: u32 = 0x006358ac; + const MAP_CURSOR_HANDLES: u32 = 0x006392c4; + const EDIT_TEXT_CURSOR_HANDLE: u32 = 0x0063ea48; + const EDIT_TEXT_CURSOR_BORROWED_FLAG: u32 = 0x0063ea4c; + + const BFAPP_RESOURCE_DLLS_START: u32 = 0x44c; + const BFAPP_RESOURCE_DLLS_END: u32 = 0x450; + const BFAPP_HWND: u32 = 0x4c4; + + const BFUIMGR_ACTIVE_ELEMENT: u32 = 0x10; + const BFUIMGR_CURRENT_CURSOR: u32 = 0x50; + const BFUIMGR_BUSY_CURSOR: u32 = 0x54; + const BFUIMGR_DEFAULT_CURSOR: u32 = 0x58; + const BFUIMGR_DEFAULT_CURSOR_BORROWED_FLAG: u32 = 0x5c; + + const UIELEMENT_CURSOR: u32 = 0xa8; + const ZTMAPVIEW_CURRENT_CURSOR_INDEX: u32 = 0x450; + + const DEFAULT_CURSOR_RESOURCE_ID: u32 = 200; + const BUSY_CURSOR_RESOURCE_ID: u32 = 0xd3; + const TEXT_CURSOR_RESOURCE_ID: u32 = 0xc9; + const MAP_CURSOR_COUNT: usize = 9; + + static MANAGER: OnceLock> = OnceLock::new(); + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct CursorHandle(u32); + + impl CursorHandle { + pub fn from_raw(raw: u32) -> Option { + (raw != 0).then_some(Self(raw)) + } + + pub fn raw(self) -> u32 { + self.0 + } + + fn as_hcursor(self) -> HCURSOR { + HCURSOR(self.0 as *mut c_void) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum CursorOwnership { + Owned, + Borrowed, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct ManagedCursor { + handle: CursorHandle, + ownership: CursorOwnership, + } + + impl ManagedCursor { + fn owned(raw: u32) -> Option { + CursorHandle::from_raw(raw).map(|handle| Self { + handle, + ownership: CursorOwnership::Owned, + }) + } + + fn borrowed(raw: u32) -> Option { + CursorHandle::from_raw(raw).map(|handle| Self { + handle, + ownership: CursorOwnership::Borrowed, + }) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[repr(u8)] + pub enum MapCursor { + Cursor0, + Cursor1, + Cursor2, + Cursor3, + Cursor4, + Cursor5, + Cursor6, + Cursor7, + Cursor8, + } + + impl MapCursor { + fn from_i32(index: i32) -> Option { + match index { + 0 => Some(Self::Cursor0), + 1 => Some(Self::Cursor1), + 2 => Some(Self::Cursor2), + 3 => Some(Self::Cursor3), + 4 => Some(Self::Cursor4), + 5 => Some(Self::Cursor5), + 6 => Some(Self::Cursor6), + 7 => Some(Self::Cursor7), + 8 => Some(Self::Cursor8), + _ => None, + } + } + + fn index(self) -> usize { + self as usize + } + } + + #[derive(Default)] + struct CursorManager { + default_cursor: Option, + busy_cursor: Option, + text_cursor: Option, + map_cursors: [Option; MAP_CURSOR_COUNT], + current_cursor: Option, + } + + impl CursorManager { + fn init_bfuimgr_cursors(&mut self, bfuimgr: *const u32) { + self.busy_cursor = load_cursor_resource(BUSY_CURSOR_RESOURCE_ID).and_then(ManagedCursor::owned); + save_to_memory(bfuimgr as u32 + BFUIMGR_BUSY_CURSOR, raw_cursor(self.busy_cursor)); + + self.default_cursor = load_cursor_resource(DEFAULT_CURSOR_RESOURCE_ID) + .and_then(ManagedCursor::owned) + .or_else(|| class_cursor().and_then(ManagedCursor::borrowed)) + .or_else(|| system_cursor(IDC_ARROW).and_then(ManagedCursor::borrowed)); + + save_to_memory(bfuimgr as u32 + BFUIMGR_DEFAULT_CURSOR, raw_cursor(self.default_cursor)); + save_to_memory( + bfuimgr as u32 + BFUIMGR_DEFAULT_CURSOR_BORROWED_FLAG, + cursor_is_borrowed(self.default_cursor) as u32, + ); + + if let Some(hwnd) = main_window() { + unsafe { + SetClassLongA(hwnd, GCL_HCURSOR, 0); + } + } + + self.set_cursor(bfuimgr, self.default_cursor.map(|cursor| cursor.handle), true); + self.init_text_cursor(); + } + + fn init_text_cursor(&mut self) { + self.text_cursor = load_cursor_resource(TEXT_CURSOR_RESOURCE_ID) + .and_then(ManagedCursor::owned) + .or_else(|| system_cursor(IDC_IBEAM).and_then(ManagedCursor::borrowed)); + + save_to_memory(EDIT_TEXT_CURSOR_HANDLE, raw_cursor(self.text_cursor)); + save_to_memory(EDIT_TEXT_CURSOR_BORROWED_FLAG, cursor_is_borrowed(self.text_cursor) as u8); + } + + fn init_map_cursors(&mut self, refresh_map_view: bool) { + for index in 0..MAP_CURSOR_COUNT { + let resource_id = get_from_memory::(MAP_CURSOR_IDS + (index * 2) as u32) as u32; + self.map_cursors[index] = load_cursor_resource(resource_id).and_then(ManagedCursor::owned); + save_to_memory(MAP_CURSOR_HANDLES + (index * 4) as u32, raw_cursor(self.map_cursors[index])); + } + + if refresh_map_view { + let map_view = unsafe { GET_MAPVIEW.original()() }; + if !map_view.is_null() { + self.use_map_cursor(map_view, -1); + } + } + } + + fn set_cursor(&mut self, bfuimgr: *const u32, cursor: Option, apply_now: bool) { + self.current_cursor = cursor; + save_to_memory(bfuimgr as u32 + BFUIMGR_CURRENT_CURSOR, cursor.map_or(0, CursorHandle::raw)); + + if apply_now { + let visible_cursor = cursor.or_else(|| self.default_cursor.map(|cursor| cursor.handle)); + unsafe { + SetCursor(visible_cursor.map(CursorHandle::as_hcursor)); + } + } + } + + fn show_busy(&mut self, bfuimgr: *const u32) { + self.set_cursor(bfuimgr, self.busy_cursor.map(|cursor| cursor.handle), true); + } + + fn hide_busy(&mut self, bfuimgr: *const u32) { + if self.current_cursor.map(CursorHandle::raw) == self.busy_cursor.map(|cursor| cursor.handle.raw()) { + self.set_cursor(bfuimgr, None, true); + } + } + + fn reset_element_cursor(&mut self, bfuimgr: *const u32) { + let active_element = get_from_memory::(bfuimgr as u32 + BFUIMGR_ACTIVE_ELEMENT); + if active_element == 0 { + return; + } + + let cursor = CursorHandle::from_raw(get_from_memory::(active_element + UIELEMENT_CURSOR)); + self.set_cursor(bfuimgr, cursor, true); + } + + fn set_element_cursor(&mut self, element: *const u32, cursor: Option) { + let old_cursor = CursorHandle::from_raw(get_from_memory::(element as u32 + UIELEMENT_CURSOR)); + if old_cursor == cursor { + return; + } + + save_to_memory(element as u32 + UIELEMENT_CURSOR, cursor.map_or(0, CursorHandle::raw)); + if cursor.is_some() { + self.set_cursor(GLOBAL_BFUIMGR as *const u32, cursor, true); + } + } + + fn use_map_cursor(&mut self, map_view: *const u32, mut index: i32) { + if index == -1 { + index = get_from_memory::(map_view as u32 + ZTMAPVIEW_CURRENT_CURSOR_INDEX); + } + + let Some(cursor_index) = MapCursor::from_i32(index) else { + return; + }; + + save_to_memory(map_view as u32 + ZTMAPVIEW_CURRENT_CURSOR_INDEX, index); + let cursor = self.map_cursors[cursor_index.index()].map(|cursor| cursor.handle); + self.set_element_cursor(map_view, cursor); + } + + fn destroy_bfuimgr_cursors(&mut self) { + destroy_managed(self.busy_cursor.take()); + destroy_managed(self.default_cursor.take()); + destroy_managed(self.text_cursor.take()); + self.current_cursor = None; + save_to_memory(EDIT_TEXT_CURSOR_HANDLE, 0u32); + save_to_memory(EDIT_TEXT_CURSOR_BORROWED_FLAG, 0u8); + } + + fn destroy_map_cursors(&mut self) { + for cursor in &mut self.map_cursors { + destroy_managed(cursor.take()); + } + + for index in 0..MAP_CURSOR_COUNT { + save_to_memory(MAP_CURSOR_HANDLES + (index * 4) as u32, 0u32); + } + } + } + + #[detour_mod] + mod cursor_hooks { + use super::*; + + #[detour(BFUIMGR_INIT_CURSORS)] + unsafe extern "thiscall" fn bfuimgr_init_cursors(this: *const u32) { + with_manager(|manager| manager.init_bfuimgr_cursors(this)); + } + + #[detour(BFUIMGR_DESTROY_CURSORS)] + unsafe extern "thiscall" fn bfuimgr_destroy_cursors(_this: *const u32) { + with_manager(CursorManager::destroy_bfuimgr_cursors); + } + + #[detour(SET_CURSOR)] + unsafe extern "thiscall" fn bfuimgr_set_cursor(this: *const u32, cursor: *const u32, apply_now: bool) { + with_manager(|manager| manager.set_cursor(this, CursorHandle::from_raw(cursor as u32), apply_now)); + } + + #[detour(SHOW_BUSY_CURSOR)] + unsafe extern "thiscall" fn bfuimgr_show_busy_cursor(this: *const u32) { + with_manager(|manager| manager.show_busy(this)); + } + + #[detour(HIDE_BUSY_CURSOR)] + unsafe extern "thiscall" fn bfuimgr_hide_busy_cursor(this: *const u32) { + with_manager(|manager| manager.hide_busy(this)); + } + + #[detour(RESET_ELEMENT_CURSOR)] + unsafe extern "thiscall" fn bfuimgr_reset_element_cursor(this: *const u32) { + with_manager(|manager| manager.reset_element_cursor(this)); + } + + #[detour(ZTMAPVIEW_INIT_CURSORS)] + unsafe extern "cdecl" fn ztmapview_init_cursors(refresh_map_view: i8) { + with_manager(|manager| manager.init_map_cursors(refresh_map_view != 0)); + } + + #[detour(ZTMAPVIEW_DESTROY_CURSORS)] + unsafe extern "stdcall" fn ztmapview_destroy_cursors() { + with_manager(CursorManager::destroy_map_cursors); + } + + #[detour(USE_CURSOR)] + unsafe extern "thiscall" fn ztmapview_use_cursor(this: *const u32, index: i32) { + with_manager(|manager| manager.use_map_cursor(this, index)); + } + } + + pub fn init() { + match unsafe { cursor_hooks::init_detours() } { + Ok(()) => info!("Initialized Rust cursor management detours"), + Err(err) => error!("Error initializing Rust cursor management detours: {err}"), + } + } + + pub fn set_default_cursor() { + with_manager(|manager| { + manager.set_cursor( + GLOBAL_BFUIMGR as *const u32, + manager.default_cursor.map(|cursor| cursor.handle), + true, + ) + }); + } + + pub fn reset_global_element_cursor() { + with_manager(|manager| manager.reset_element_cursor(GLOBAL_BFUIMGR as *const u32)); + } + + fn with_manager(action: impl FnOnce(&mut CursorManager)) { + let storage = MANAGER.get_or_init(|| Mutex::new(CursorManager::default())); + match storage.lock() { + Ok(mut manager) => action(&mut manager), + Err(err) => error!("cursor manager lock poisoned: {err}"), + } + } + + fn raw_cursor(cursor: Option) -> u32 { + cursor.map_or(0, |cursor| cursor.handle.raw()) + } + + fn cursor_is_borrowed(cursor: Option) -> bool { + matches!( + cursor, + Some(ManagedCursor { + ownership: CursorOwnership::Borrowed, + .. + }) + ) + } + + fn load_cursor_resource(resource_id: u32) -> Option { + let id = adjusted_resource_id(resource_id) & 0xffff; + let start = get_from_memory::(GLOBAL_BFAPP + BFAPP_RESOURCE_DLLS_START); + let end = get_from_memory::(GLOBAL_BFAPP + BFAPP_RESOURCE_DLLS_END); + if start == 0 || end < start { + return None; + } + + let mut cursor = start; + while cursor < end { + let instance = get_from_memory::(cursor); + if instance != 0 { + let loaded = unsafe { + LoadImageA( + Some(HINSTANCE(instance as *mut c_void)), + PCSTR(id as usize as *const u8), + IMAGE_CURSOR, + 0, + 0, + Default::default(), + ) + }; + if let Ok(handle) = loaded + && !handle.0.is_null() + { + return Some(handle.0 as u32); + } + } + cursor += 4; + } + + None + } + + fn adjusted_resource_id(resource_id: u32) -> u32 { + if get_from_memory::(CURSOR_RESOURCE_OFFSET_ENABLED) != 0 { + resource_id.wrapping_add(get_from_memory::(CURSOR_RESOURCE_OFFSET)) + } else { + resource_id + } + } + + fn class_cursor() -> Option { + let hwnd = main_window()?; + let cursor = unsafe { GetClassLongA(hwnd, GCL_HCURSOR) }; + (cursor != 0).then_some(cursor as u32) + } + + fn system_cursor(id: PCWSTR) -> Option { + match unsafe { LoadCursorW(None, id) } { + Ok(cursor) if !cursor.0.is_null() => Some(cursor.0 as u32), + Ok(_) => None, + Err(err) => { + warn!("failed to load system cursor: {err}"); + None + } + } + } + + fn main_window() -> Option { + let hwnd = get_from_memory::(GLOBAL_BFAPP + BFAPP_HWND); + if hwnd != 0 { + return Some(HWND(hwnd as *mut c_void)); + } + unsafe { FindWindowA(PCSTR::null(), windows::core::s!("Zoo Tycoon")).ok().filter(|hwnd| !hwnd.0.is_null()) } + } + + fn destroy_managed(cursor: Option) { + let Some(cursor) = cursor else { + return; + }; + + if cursor.ownership == CursorOwnership::Owned { + unsafe { + if let Err(err) = DestroyCursor(cursor.handle.as_hcursor()) { + warn!("failed to destroy cursor {:#x}: {err}", cursor.handle.raw()); + } + } + } + } +} + +#[cfg(target_os = "windows")] +pub use imp::*; + +#[cfg(not(target_os = "windows"))] +pub fn init() {} diff --git a/openzt/src/expansions.rs b/openzt/src/expansions.rs index 6166cc5..ad507c2 100644 --- a/openzt/src/expansions.rs +++ b/openzt/src/expansions.rs @@ -276,7 +276,7 @@ fn inner_save_mutex(mut mutex_guard: MutexGuard>) { }); } -fn get_expansions() -> Vec { +pub fn get_expansions() -> Vec { EXPANSION_ARRAY.lock().unwrap().clone() } @@ -302,6 +302,23 @@ impl Expansion { fn name_string(&self) -> String { get_string_from_memory_bounded(self.name_string_start_ptr, self.name_string_end_ptr, self.name_string_buffer_end_ptr) } + + /// The registry-side id (raw `.cfg` `expansion`/`id` value `+1` for official expansions - see + /// `parse_expansion_config` - or an OpenZT-assigned id `>= 4` for custom ones). This is what + /// `ZTUI::expansionselect::isExpansionDisabled` and `filter_entity_type` compare against, and is + /// `ZTResearchCategory::expansion_id() + 1` for the matching expansion (see that field's doc + /// comment in `ztresearch.rs`). + pub fn expansion_id(&self) -> u32 { + self.expansion_id + } + + /// The string-table/registry id naming this expansion for display. Always resolvable via + /// `string_registry::load_string_by_id` regardless of whether this is a vanilla expansion (a + /// plain string-table id, e.g. from `xpac0N.cfg`'s `listid=`) or an OpenZT-registered custom one + /// (`>= 100_000`) - `load_string_by_id` already checks the OpenZT registry first. + pub fn name_id(&self) -> u32 { + self.name_id + } } fn read_expansion_list_from_memory() -> ExpansionList { @@ -381,7 +398,7 @@ pub mod custom_expansion { use crate::{bfentitytype::read_zt_entity_type_from_memory, ztui::get_current_buy_tab}; #[detour(ENTITY_TYPE_IS_DISPLAYED)] - pub unsafe extern "cdecl" fn ztui_general_entity_type_is_displayed(bf_entity: u32, param_1: u32, param_2: u32) -> bool { + pub unsafe extern "cdecl" fn ztui_general_entity_type_is_displayed(bf_entity: *const i32, param_1: *const i8, param_2: *const i8) -> bool { // TODO: Put this call and subsequent log behind OpenZT debug flag) let result = unsafe { ENTITY_TYPE_IS_DISPLAYED_DETOUR.call(bf_entity, param_1, param_2) }; @@ -389,7 +406,7 @@ pub mod custom_expansion { return false; }; - let entity = read_zt_entity_type_from_memory(bf_entity); + let entity = read_zt_entity_type_from_memory(bf_entity as u32); let Some(current_buy_tab) = get_current_buy_tab() else { return false; diff --git a/openzt/src/lib.rs b/openzt/src/lib.rs index ef36e83..7f92ada 100644 --- a/openzt/src/lib.rs +++ b/openzt/src/lib.rs @@ -98,6 +98,12 @@ mod shortcuts; /// Patches in the current OpenZT build version into the game's version string. mod version; +#[cfg(all(target_os = "windows", feature = "experimental"))] +mod cursors; + +#[cfg(all(target_os = "windows", feature = "experimental", feature = "egui-overlay"))] +mod ui; + // TODO: Move this to resource_manager/openzt_mods /// OpenZT mod structs mod mods; @@ -175,6 +181,10 @@ mod zoo_init { bfentitytype::init(); settings::init(); scripting::init(); + #[cfg(all(target_os = "windows", feature = "experimental"))] + cursors::init(); + #[cfg(all(target_os = "windows", feature = "egui-overlay"))] + ui::init(); shortcuts::init(); roofs::init(); diff --git a/openzt/src/settings.rs b/openzt/src/settings.rs index 5536a1d..3c54c69 100644 --- a/openzt/src/settings.rs +++ b/openzt/src/settings.rs @@ -6,6 +6,31 @@ use tracing::error; mod ai; mod debug; mod util; +mod zoo_ini; + +pub(crate) fn reload_zoo_ini_settings() -> Result<(), String> { + zoo_ini::reload_zoo_ini_settings() +} + +pub(crate) fn get_zoo_setting(section: &str, key: &str) -> Option { + zoo_ini::get_zoo_setting(section, key) +} + +pub(crate) fn get_zoo_setting_vec(section: &str, key: &str) -> Option> { + zoo_ini::get_zoo_setting_vec(section, key) +} + +pub(crate) fn get_zoo_setting_bool(section: &str, key: &str) -> Result, String> { + zoo_ini::get_zoo_setting_bool(section, key) +} + +pub(crate) fn get_zoo_setting_i32(section: &str, key: &str) -> Result, String> { + zoo_ini::get_zoo_setting_i32(section, key) +} + +pub(crate) fn zoo_setting_sections() -> Vec { + zoo_ini::zoo_setting_sections() +} fn command_get_setting(args: Vec<&str>) -> Result { if args.len() != 2 { @@ -69,6 +94,10 @@ mod zoo_ini_loading { } pub fn init() { + if let Err(e) = reload_zoo_ini_settings() { + error!("Error loading zoo.ini settings: {}", e); + } + // get_setting(section, key) - two string args lua_fn!("get_setting", "Gets a setting value", "get_setting(section, key)", |section: String, key: String| { match command_get_setting(vec![§ion, &key]) { diff --git a/openzt/src/settings/zoo_ini.rs b/openzt/src/settings/zoo_ini.rs new file mode 100644 index 0000000..e7cdab5 --- /dev/null +++ b/openzt/src/settings/zoo_ini.rs @@ -0,0 +1,107 @@ +use std::sync::{LazyLock, Mutex}; + +use openzt_configparser::ini::Ini; +use tracing::{info, warn}; + +use crate::util::get_ini_path; + +static ZOO_INI_SETTINGS: LazyLock> = LazyLock::new(|| Mutex::new(new_zoo_ini())); + +fn new_zoo_ini() -> Ini { + let mut ini = Ini::new(); + ini.set_comment_symbols(&['#']); + ini +} + +fn load_from_disk() -> Result { + let path = get_ini_path(); + let mut ini = new_zoo_ini(); + ini.load(&path)?; + info!("Loaded zoo.ini settings from {}", path.display()); + Ok(ini) +} + +pub(crate) fn reload_zoo_ini_settings() -> Result<(), String> { + let loaded = load_from_disk(); + let mut settings = ZOO_INI_SETTINGS.lock().unwrap(); + match loaded { + Ok(ini) => { + *settings = ini; + Ok(()) + } + Err(e) => { + warn!("Failed to load zoo.ini settings: {}", e); + *settings = new_zoo_ini(); + Err(e) + } + } +} + +pub(crate) fn get_zoo_setting(section: &str, key: &str) -> Option { + ZOO_INI_SETTINGS.lock().unwrap().get(section, key) +} + +pub(crate) fn get_zoo_setting_vec(section: &str, key: &str) -> Option> { + ZOO_INI_SETTINGS.lock().unwrap().get_vec(section, key) +} + +pub(crate) fn get_zoo_setting_bool(section: &str, key: &str) -> Result, String> { + ZOO_INI_SETTINGS.lock().unwrap().get_bool_coerce(section, key) +} + +pub(crate) fn get_zoo_setting_i32(section: &str, key: &str) -> Result, String> { + ZOO_INI_SETTINGS.lock().unwrap().get_parse(section, key) +} + +pub(crate) fn zoo_setting_sections() -> Vec { + ZOO_INI_SETTINGS.lock().unwrap().sections() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_test_ini(input: &str) -> Ini { + let mut ini = new_zoo_ini(); + ini.read(input.to_string()).unwrap(); + ini + } + + #[test] + fn lookup_is_case_insensitive() { + let ini = read_test_ini("[debug]\nlogCutoff=15\n"); + + assert_eq!(ini.get("Debug", "logCutoff"), Some("15".to_string())); + assert_eq!(ini.get("DEBUG", "LOGCUTOFF"), Some("15".to_string())); + } + + #[test] + fn reads_string_bool_and_i32_values() { + let ini = read_test_ini("[user]\nfullscreen=0\nscreenwidth=1024\nlastfile=test.zoo\n"); + + assert_eq!(ini.get("user", "lastfile"), Some("test.zoo".to_string())); + assert_eq!(ini.get_bool_coerce("user", "fullscreen"), Ok(Some(false))); + assert_eq!(ini.get_parse::("user", "screenwidth"), Ok(Some(1024))); + } + + #[test] + fn missing_values_return_none() { + let ini = read_test_ini("[user]\nfullscreen=0\n"); + + assert_eq!(ini.get("missing", "fullscreen"), None); + assert_eq!(ini.get("user", "missing"), None); + assert_eq!(ini.get_bool_coerce("user", "missing"), Ok(None)); + assert_eq!(ini.get_parse::("user", "missing"), Ok(None)); + } + + #[test] + fn duplicate_keys_keep_all_values_for_vec_lookup() { + let ini = read_test_ini("[resource]\npath=first\npath=second\n"); + + assert_eq!(ini.get("resource", "path"), Some("second".to_string())); + assert_eq!( + ini.get_vec("resource", "path"), + Some(vec!["first".to_string(), "second".to_string()]) + ); + } +} diff --git a/openzt/src/ui/blit.rs b/openzt/src/ui/blit.rs new file mode 100644 index 0000000..b109dab --- /dev/null +++ b/openzt/src/ui/blit.rs @@ -0,0 +1,312 @@ +use std::ffi::c_void; +use std::ptr; +use std::sync::{Mutex, OnceLock}; + +use tiny_skia::Pixmap; +use tracing::{info, warn}; +use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, POINT, RECT, SIZE, WPARAM}; +use windows::Win32::Graphics::Gdi::{ + AC_SRC_ALPHA, BI_RGB, BITMAPINFO, BITMAPINFOHEADER, BLENDFUNCTION, ClientToScreen, CreateCompatibleDC, CreateDIBSection, DIB_RGB_COLORS, DeleteDC, DeleteObject, + GetDC, HGDIOBJ, ReleaseDC, SelectObject, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleA; +use windows::Win32::UI::WindowsAndMessaging::{ + CS_HREDRAW, CS_VREDRAW, CreateWindowExA, DefWindowProcA, RegisterClassA, SW_HIDE, SW_SHOWNOACTIVATE, SWP_NOACTIVATE, SWP_NOZORDER, SWP_SHOWWINDOW, SetWindowPos, + ShowWindow, ULW_ALPHA, UpdateLayeredWindow, WNDCLASSA, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_POPUP, +}; +use windows::core::PCSTR; + +const OVERLAY_CLASS: PCSTR = windows::core::s!("OpenZTLayeredOverlay"); + +static OVERLAY: OnceLock> = OnceLock::new(); +static CLASS_REGISTERED: OnceLock<()> = OnceLock::new(); + +#[derive(Default)] +struct LayeredOverlay { + hwnd: Option, + owner: Option, +} + +pub fn blit_to_hwnd(hwnd: HWND, pixmap: &Pixmap) { + let Some(rect) = client_screen_rect(hwnd) else { + return; + }; + + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || height <= 0 || pixmap.width() != width as u32 || pixmap.height() != height as u32 { + return; + } + + let overlay = OVERLAY.get_or_init(|| Mutex::new(LayeredOverlay::default())); + let mut overlay = match overlay.lock() { + Ok(overlay) => overlay, + Err(err) => { + warn!("egui overlay: layered overlay lock poisoned: {err}"); + return; + } + }; + + let Some(overlay_hwnd) = overlay.ensure(hwnd) else { + return; + }; + + if let Err(err) = update_overlay_window(overlay_hwnd, rect, pixmap) { + warn!("egui overlay: UpdateLayeredWindow failed: {err}"); + } +} + +pub fn sync_overlay_position(owner: HWND) { + let Some(rect) = client_screen_rect(owner) else { + return; + }; + + let overlay = OVERLAY.get_or_init(|| Mutex::new(LayeredOverlay::default())); + let mut overlay = match overlay.lock() { + Ok(overlay) => overlay, + Err(err) => { + warn!("egui overlay: layered overlay lock poisoned: {err}"); + return; + } + }; + + let Some(overlay_hwnd) = overlay.ensure(owner) else { + return; + }; + + if let Err(err) = position_overlay_window(overlay_hwnd, rect) { + warn!("egui overlay: SetWindowPos failed while syncing position: {err}"); + } +} + +pub fn hide_overlay() { + let Some(overlay) = OVERLAY.get() else { + return; + }; + + let overlay = match overlay.lock() { + Ok(overlay) => overlay, + Err(err) => { + warn!("egui overlay: layered overlay lock poisoned: {err}"); + return; + } + }; + + if let Some(hwnd_raw) = overlay.hwnd { + unsafe { + let _ = ShowWindow(HWND(hwnd_raw as *mut c_void), SW_HIDE); + } + } +} + +impl LayeredOverlay { + fn ensure(&mut self, owner: HWND) -> Option { + let owner_raw = owner.0 as isize; + if self.owner == Some(owner_raw) + && let Some(hwnd_raw) = self.hwnd + { + return Some(HWND(hwnd_raw as *mut c_void)); + } + + register_overlay_class(); + + let module = match unsafe { GetModuleHandleA(PCSTR::null()) } { + Ok(module) => HINSTANCE(module.0), + Err(err) => { + warn!("egui overlay: GetModuleHandleA failed: {err}"); + return None; + } + }; + + let hwnd = match unsafe { + CreateWindowExA( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + OVERLAY_CLASS, + windows::core::s!("OpenZT Overlay"), + WS_POPUP, + 0, + 0, + 1, + 1, + Some(owner), + None, + Some(module), + None, + ) + } { + Ok(hwnd) => hwnd, + Err(err) => { + warn!("egui overlay: CreateWindowExA failed: {err}"); + return None; + } + }; + + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + + self.owner = Some(owner_raw); + self.hwnd = Some(hwnd.0 as isize); + info!("egui overlay: created layered overlay HWND {:#x}", hwnd.0 as isize); + Some(hwnd) + } +} + +fn register_overlay_class() { + CLASS_REGISTERED.get_or_init(|| { + let module = match unsafe { GetModuleHandleA(PCSTR::null()) } { + Ok(module) => HINSTANCE(module.0), + Err(err) => { + warn!("egui overlay: GetModuleHandleA failed while registering overlay class: {err}"); + return; + } + }; + + let class = WNDCLASSA { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(overlay_wndproc), + hInstance: module, + lpszClassName: OVERLAY_CLASS, + ..Default::default() + }; + + let atom = unsafe { RegisterClassA(&class) }; + if atom == 0 { + warn!("egui overlay: RegisterClassA returned 0 for overlay class"); + } + }); +} + +unsafe extern "system" fn overlay_wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + unsafe { DefWindowProcA(hwnd, msg, wparam, lparam) } +} + +fn client_screen_rect(hwnd: HWND) -> Option { + let mut rect = RECT::default(); + if let Err(err) = unsafe { windows::Win32::UI::WindowsAndMessaging::GetClientRect(hwnd, &mut rect) } { + warn!("egui overlay: GetClientRect failed: {err}"); + return None; + } + + let mut origin = POINT { x: rect.left, y: rect.top }; + if !unsafe { ClientToScreen(hwnd, &mut origin) }.as_bool() { + warn!("egui overlay: ClientToScreen failed"); + return None; + } + + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + Some(RECT { + left: origin.x, + top: origin.y, + right: origin.x + width, + bottom: origin.y + height, + }) +} + +fn update_overlay_window(hwnd: HWND, rect: RECT, pixmap: &Pixmap) -> windows::core::Result<()> { + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + + unsafe { + position_overlay_window(hwnd, rect)?; + + let screen_dc = GetDC(None); + if screen_dc.is_invalid() { + warn!("egui overlay: GetDC(None) failed"); + return Ok(()); + } + + let mem_dc = CreateCompatibleDC(Some(screen_dc)); + if mem_dc.is_invalid() { + warn!("egui overlay: CreateCompatibleDC failed"); + ReleaseDC(None, screen_dc); + return Ok(()); + } + + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + + let mut bits: *mut c_void = ptr::null_mut(); + let bitmap = match CreateDIBSection(Some(screen_dc), &info, DIB_RGB_COLORS, &mut bits, None, 0) { + Ok(bitmap) => bitmap, + Err(err) => { + let _ = DeleteDC(mem_dc); + ReleaseDC(None, screen_dc); + return Err(err); + } + }; + + let bitmap_obj = HGDIOBJ(bitmap.0); + let old_obj = SelectObject(mem_dc, bitmap_obj); + if old_obj.is_invalid() { + warn!("egui overlay: SelectObject failed"); + let _ = DeleteObject(bitmap_obj); + let _ = DeleteDC(mem_dc); + ReleaseDC(None, screen_dc); + return Ok(()); + } + + if bits.is_null() { + warn!("egui overlay: DIB section returned null bits"); + } else { + copy_pixmap_to_layered_dib(pixmap, bits.cast::()); + let destination = POINT { x: rect.left, y: rect.top }; + let size = SIZE { cx: width, cy: height }; + let source = POINT { x: 0, y: 0 }; + let blend = BLENDFUNCTION { + BlendOp: 0, + BlendFlags: 0, + SourceConstantAlpha: 255, + AlphaFormat: AC_SRC_ALPHA as u8, + }; + + UpdateLayeredWindow( + hwnd, + Some(screen_dc), + Some(&destination), + Some(&size), + Some(mem_dc), + Some(&source), + COLORREF(0), + Some(&blend), + ULW_ALPHA, + )?; + } + + SelectObject(mem_dc, old_obj); + let _ = DeleteObject(bitmap_obj); + let _ = DeleteDC(mem_dc); + ReleaseDC(None, screen_dc); + } + + Ok(()) +} + +fn position_overlay_window(hwnd: HWND, rect: RECT) -> windows::core::Result<()> { + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + unsafe { SetWindowPos(hwnd, None, rect.left, rect.top, width, height, SWP_NOACTIVATE | SWP_NOZORDER | SWP_SHOWWINDOW) } +} + +fn copy_pixmap_to_layered_dib(pixmap: &Pixmap, dib_bits: *mut u8) { + for (index, pixel) in pixmap.pixels().iter().enumerate() { + let offset = index * 4; + unsafe { + *dib_bits.add(offset) = pixel.blue(); + *dib_bits.add(offset + 1) = pixel.green(); + *dib_bits.add(offset + 2) = pixel.red(); + *dib_bits.add(offset + 3) = pixel.alpha(); + } + } +} diff --git a/openzt/src/ui/cursor.rs b/openzt/src/ui/cursor.rs new file mode 100644 index 0000000..6b67e12 --- /dev/null +++ b/openzt/src/ui/cursor.rs @@ -0,0 +1,26 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use egui::CursorIcon; + +static OVERLAY_CURSOR_ACTIVE: AtomicBool = AtomicBool::new(false); + +pub fn init() {} + +pub fn apply_egui_cursor(cursor_icon: CursorIcon, wants_pointer_input: bool) { + let wants_vanilla_arrow = wants_pointer_input || cursor_icon != CursorIcon::Default; + let was_active = OVERLAY_CURSOR_ACTIVE.swap(wants_vanilla_arrow, Ordering::Relaxed); + + if wants_vanilla_arrow { + if !was_active { + crate::cursors::set_default_cursor(); + } + } else if was_active { + crate::cursors::reset_global_element_cursor(); + } +} + +pub fn clear_egui_cursor() { + if OVERLAY_CURSOR_ACTIVE.swap(false, Ordering::Relaxed) { + crate::cursors::reset_global_element_cursor(); + } +} diff --git a/openzt/src/ui/date_display.rs b/openzt/src/ui/date_display.rs new file mode 100644 index 0000000..7bbd754 --- /dev/null +++ b/openzt/src/ui/date_display.rs @@ -0,0 +1,109 @@ +use std::sync::{Mutex, OnceLock}; + +use openzt_detour::generated::ztui_main::SET_DATE_TEXT; +use openzt_detour_macro::detour_mod; +use nt_time::time::{Month, UtcDateTime, Weekday}; +use tracing::{error, info}; + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(SET_DATE_TEXT)] + unsafe extern "stdcall" fn ztui_main_set_date_text() { + unsafe { SET_DATE_TEXT_DETOUR.call() }; + if let Some(date) = crate::globals::globals().ztgamemgr().date() { + crate::ui::vanilla_main::set_date_value(date); + } + } +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized ZTUI::main::setDateText detour"), + Err(err) => error!("egui overlay: failed to initialize date display detour: {err}"), + } +} + +static DATE_DISPLAY: OnceLock> = OnceLock::new(); + +struct DateDisplay { + text: String, + date: Option, +} + +pub fn set_value(date: UtcDateTime) { + if let Ok(mut display) = DATE_DISPLAY.get_or_init(|| Mutex::new(default_display())).lock() { + display.text = crate::ztgamemgr::format_game_date(date); + display.date = Some(date); + } +} + +pub fn current() -> String { + DATE_DISPLAY + .get_or_init(|| Mutex::new(default_display())) + .lock() + .map(|display| display.text.clone()) + .unwrap_or_else(|_| default_text()) +} + +pub fn tooltip_text() -> Option { + let display = DATE_DISPLAY.get_or_init(|| Mutex::new(default_display())).lock().ok()?; + let date = display.date.or_else(|| crate::globals::globals().ztgamemgr().date())?; + let help_id = if super::tooltip::long_tooltips_enabled() { 31_030 } else { 1_030 }; + let prefix = crate::string_registry::load_string_by_id(help_id).unwrap_or_default(); + Some(format_date_help_text(&prefix, date)) +} + +fn format_date_help_text(template: &str, date: UtcDateTime) -> String { + let formatted_date = format_tooltip_date(date); + if template.contains("%s") { + template.replace("%s", &formatted_date) + } else { + format!("{}{}", template, formatted_date) + } +} + +fn format_tooltip_date(date: UtcDateTime) -> String { + format!("{}. {} {}", weekday_abbreviation(date.weekday()), month_abbreviation(date.month()), date.day()) +} + +fn weekday_abbreviation(weekday: Weekday) -> &'static str { + match weekday { + Weekday::Monday => "Mon", + Weekday::Tuesday => "Tue", + Weekday::Wednesday => "Wed", + Weekday::Thursday => "Thu", + Weekday::Friday => "Fri", + Weekday::Saturday => "Sat", + Weekday::Sunday => "Sun", + } +} + +fn month_abbreviation(month: Month) -> &'static str { + match month { + Month::January => "Jan", + Month::February => "Feb", + Month::March => "Mar", + Month::April => "Apr", + Month::May => "May", + Month::June => "Jun", + Month::July => "Jul", + Month::August => "Aug", + Month::September => "Sep", + Month::October => "Oct", + Month::November => "Nov", + Month::December => "Dec", + } +} + +fn default_display() -> DateDisplay { + DateDisplay { + text: default_text(), + date: None, + } +} + +fn default_text() -> String { + "Jan 1, Year 1".to_string() +} diff --git a/openzt/src/ui/input_block.rs b/openzt/src/ui/input_block.rs new file mode 100644 index 0000000..7f01880 --- /dev/null +++ b/openzt/src/ui/input_block.rs @@ -0,0 +1,24 @@ +use openzt_detour::generated::ztmapview::CHECK_MOUSE_OVER_ENTITY; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +#[detour_mod] +mod input_block_hooks { + use super::*; + + #[detour(CHECK_MOUSE_OVER_ENTITY)] + unsafe extern "thiscall" fn ztmapview_check_mouse_over_entity(this: *const u32) { + if crate::ui::blocks_pointer_input() { + return; + } + + unsafe { CHECK_MOUSE_OVER_ENTITY_DETOUR.call(this) }; + } +} + +pub fn init() { + match unsafe { input_block_hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized input block detours"), + Err(err) => error!("egui overlay: failed to initialize input block detours: {err}"), + } +} diff --git a/openzt/src/ui/live_game.rs b/openzt/src/ui/live_game.rs new file mode 100644 index 0000000..0a578cf --- /dev/null +++ b/openzt/src/ui/live_game.rs @@ -0,0 +1,27 @@ +use openzt_detour::generated::ztgamemgr::{START, STOP}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(START)] + unsafe extern "fastcall" fn ztgamemgr_start(this: i32) { + unsafe { START_DETOUR.call(this) }; + crate::ui::set_live_game_active(true); + } + + #[detour(STOP)] + unsafe extern "fastcall" fn ztgamemgr_stop(this: i32) { + crate::ui::set_live_game_active(false); + unsafe { STOP_DETOUR.call(this) }; + } +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized ZTGameMgr live-game detours"), + Err(err) => error!("egui overlay: failed to initialize live-game detours: {err}"), + } +} diff --git a/openzt/src/ui/mod.rs b/openzt/src/ui/mod.rs new file mode 100644 index 0000000..d9de7c3 --- /dev/null +++ b/openzt/src/ui/mod.rs @@ -0,0 +1,433 @@ +mod cursor; +mod blit; +mod date_display; +mod input_block; +mod live_game; +mod money_display; +mod pause; +mod render_hook; +mod status_display; +mod tga; +mod tooltip; +mod vanilla_main; +mod wndproc; +mod zt_image; +mod zoom_block; + +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; + +use crate::shortcuts::{Ctrl, Shift, U, V}; +use egui::{CursorIcon, Event, Pos2, Sense, Stroke, StrokeKind, Vec2}; +use egui_tiny_skia::TinySkiaBackend; +use tracing::{error, info, warn}; +use windows::Win32::Foundation::{HWND, POINT, RECT}; +use windows::Win32::Graphics::Gdi::ScreenToClient; +use windows::Win32::UI::WindowsAndMessaging::{FindWindowA, GetClientRect, GetCursorPos}; +use windows::core::PCSTR; + +static BACKEND: OnceLock> = OnceLock::new(); +static INPUT_EVENTS: OnceLock>> = OnceLock::new(); +static LAST_POINTER_POS: OnceLock>> = OnceLock::new(); +static LAST_CLIENT_SIZE: OnceLock>> = OnceLock::new(); +static HWND_RAW: OnceLock>> = OnceLock::new(); +static CAPTURE_STATE: OnceLock> = OnceLock::new(); +static START_TIME: OnceLock = OnceLock::new(); +static LIVE_GAME_ACTIVE: AtomicBool = AtomicBool::new(false); +static SHOW_VANILLA_UI: AtomicBool = AtomicBool::new(true); +static SHOW_DEBUG_WINDOW: AtomicBool = AtomicBool::new(true); +const OPENZT_WINDOW_RESIZABLE: bool = true; + +#[derive(Default)] +struct InputCaptureState { + pointer_over_resize_bounds: bool, + wants_pointer_input: bool, + wants_keyboard_input: bool, + platform_output_warned: bool, +} + +pub fn init() { + info!("Initializing egui overlay"); + + if let Some(hwnd) = find_zoo_window() { + capture_hwnd(hwnd); + } else { + info!("egui overlay: Zoo Tycoon HWND not available yet; waiting for window capture"); + } + + cursor::init(); + date_display::init(); + input_block::init(); + live_game::init(); + money_display::init(); + pause::init(); + status_display::init(); + tooltip::init(); + zoom_block::init(); + register_shortcuts(); + wndproc::init(); + render_hook::init(); +} + +pub fn render_and_blit(hwnd: HWND) { + if !is_live_game_active() { + blit::hide_overlay(); + return; + } + + let Some((width, height)) = client_size(hwnd) else { + return; + }; + remember_client_size(width, height); + + #[cfg(feature = "debug-blit")] + { + use tiny_skia::{Color, Pixmap}; + + let mut pixmap = match Pixmap::new(width, height) { + Some(pixmap) => pixmap, + None => return, + }; + pixmap.fill(Color::from_rgba8(255, 0, 0, 255)); + blit::blit_to_hwnd(hwnd, &pixmap); + } + + #[cfg(not(feature = "debug-blit"))] + { + let events = drain_input_events(); + + let backend_lock = BACKEND.get_or_init(|| Mutex::new(TinySkiaBackend::new(width, height))); + let mut backend = match backend_lock.lock() { + Ok(backend) => backend, + Err(err) => { + error!("egui overlay: backend lock poisoned: {err}"); + return; + } + }; + + if backend.pixmap().width() != width || backend.pixmap().height() != height { + backend.resize(width, height); + } + + let raw_input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32))), + time: Some(START_TIME.get_or_init(Instant::now).elapsed().as_secs_f64()), + events, + ..Default::default() + }; + + let output = backend.run_frame(raw_input, |ctx| { + ctx.style_mut(|style| { + style.visuals.window_shadow = egui::epaint::Shadow::NONE; + style.visuals.popup_shadow = egui::epaint::Shadow::NONE; + }); + + if SHOW_VANILLA_UI.load(Ordering::Acquire) { + vanilla_main::show(ctx, egui::vec2(width as f32, height as f32)); + } + tooltip::show(ctx, egui::vec2(width as f32, height as f32)); + + if SHOW_DEBUG_WINDOW.load(Ordering::Acquire) { + show_debug_panel(ctx); + } + }); + + { + let mut state = capture_state(); + let pointer_over_resize_bounds = OPENZT_WINDOW_RESIZABLE && is_resize_cursor(output.platform_output.cursor_icon); + let pointer_over_blocking_overlay = current_pointer_pos().is_some_and(blocks_pointer_overlay_at); + state.pointer_over_resize_bounds = pointer_over_resize_bounds; + state.wants_pointer_input = backend.context().wants_pointer_input(); + state.wants_keyboard_input = backend.context().wants_keyboard_input(); + cursor::apply_egui_cursor( + output.platform_output.cursor_icon, + state.pointer_over_resize_bounds || state.wants_pointer_input || pointer_over_blocking_overlay, + ); + if !state.platform_output_warned && !output.platform_output.commands.is_empty() { + warn!("egui overlay: platform output requested but not implemented"); + state.platform_output_warned = true; + } + } + + let pixmap = backend.paint(output); + blit::blit_to_hwnd(hwnd, pixmap); + } +} + +pub fn sync_overlay_position(hwnd: HWND) { + if !is_live_game_active() { + return; + } + + blit::sync_overlay_position(hwnd); +} + +pub fn push_event(event: Event) { + if !is_live_game_active() { + return; + } + + remember_pointer_pos(&event); + + let events = INPUT_EVENTS.get_or_init(|| Mutex::new(Vec::new())); + match events.lock() { + Ok(mut events) => events.push(event), + Err(err) => error!("egui overlay: input event lock poisoned: {err}"), + } +} + +pub fn last_pointer_pos() -> Option { + *LAST_POINTER_POS.get_or_init(|| Mutex::new(None)).lock().ok()? +} + +pub fn current_pointer_pos() -> Option { + let Some(hwnd) = captured_hwnd() else { + return None; + }; + let mut point = POINT::default(); + if unsafe { GetCursorPos(&mut point) }.is_err() { + return None; + } + if !unsafe { ScreenToClient(hwnd, &mut point) }.as_bool() { + return None; + } + + Some(Pos2::new(point.x as f32, point.y as f32)) +} + +pub fn wants_pointer_input() -> bool { + if !is_live_game_active() { + return false; + } + + capture_state().wants_pointer_input +} + +pub fn blocks_pointer_input() -> bool { + if !is_live_game_active() { + return false; + } + + frame_blocks_pointer_input() + || current_pointer_pos() + .or_else(last_pointer_pos) + .is_some_and(blocks_pointer_overlay_at) +} + +pub fn blocks_pointer_input_at(pos: Pos2) -> bool { + if !is_live_game_active() { + return false; + } + + frame_blocks_pointer_input() || blocks_pointer_overlay_at(pos) +} + +fn frame_blocks_pointer_input() -> bool { + let state = capture_state(); + state.pointer_over_resize_bounds || state.wants_pointer_input +} + +fn blocks_pointer_overlay_at(pos: Pos2) -> bool { + let Some(screen_size) = last_client_size() else { + return false; + }; + + if SHOW_VANILLA_UI.load(Ordering::Acquire) && vanilla_main::blocks_pointer_at(pos, screen_size) { + return true; + } + + SHOW_DEBUG_WINDOW.load(Ordering::Acquire) && debug_panel_rect(screen_size).contains(pos) +} + +pub fn wants_keyboard_input() -> bool { + if !is_live_game_active() { + return false; + } + + capture_state().wants_keyboard_input +} + +pub fn capture_hwnd(hwnd: HWND) { + if hwnd.0.is_null() { + return; + } + + let hwnd_raw = hwnd.0 as isize; + let storage = HWND_RAW.get_or_init(|| Mutex::new(None)); + let mut should_subclass = false; + match storage.lock() { + Ok(mut stored) => { + if *stored != Some(hwnd_raw) { + *stored = Some(hwnd_raw); + should_subclass = true; + } + } + Err(err) => { + error!("egui overlay: HWND storage lock poisoned: {err}"); + return; + } + } + + if should_subclass { + info!("egui overlay: captured HWND {hwnd_raw:#x}"); + wndproc::subclass(hwnd); + } +} + +pub fn captured_hwnd() -> Option { + let hwnd_raw = *HWND_RAW.get_or_init(|| Mutex::new(None)).lock().ok()?; + hwnd_raw.map(|raw| HWND(raw as *mut c_void)) +} + +pub fn set_live_game_active(active: bool) { + if LIVE_GAME_ACTIVE.swap(active, Ordering::AcqRel) == active { + return; + } + + if active { + info!("egui overlay: live game started"); + pause::set_paused(false); + } else { + info!("egui overlay: live game stopped"); + drain_input_events(); + reset_capture_state(); + blit::hide_overlay(); + cursor::clear_egui_cursor(); + } +} + +pub fn is_live_game_active() -> bool { + LIVE_GAME_ACTIVE.load(Ordering::Acquire) +} + +pub fn is_vanilla_ui_visible() -> bool { + is_live_game_active() && SHOW_VANILLA_UI.load(Ordering::Acquire) +} + +fn drain_input_events() -> Vec { + let events = INPUT_EVENTS.get_or_init(|| Mutex::new(Vec::new())); + match events.lock() { + Ok(mut events) => events.drain(..).collect(), + Err(err) => { + error!("egui overlay: input event lock poisoned: {err}"); + Vec::new() + } + } +} + +fn capture_state() -> std::sync::MutexGuard<'static, InputCaptureState> { + CAPTURE_STATE + .get_or_init(|| Mutex::new(InputCaptureState::default())) + .lock() + .expect("egui overlay capture state lock poisoned") +} + +fn reset_capture_state() { + *capture_state() = InputCaptureState::default(); +} + +fn remember_client_size(width: u32, height: u32) { + if let Ok(mut stored) = LAST_CLIENT_SIZE.get_or_init(|| Mutex::new(None)).lock() { + *stored = Some(Vec2::new(width as f32, height as f32)); + } +} + +fn last_client_size() -> Option { + *LAST_CLIENT_SIZE.get_or_init(|| Mutex::new(None)).lock().ok()? +} + +fn remember_pointer_pos(event: &Event) { + let pos = match event { + Event::PointerMoved(pos) => Some(*pos), + Event::PointerButton { pos, .. } => Some(*pos), + _ => None, + }; + + if let Some(pos) = pos { + if let Ok(mut stored) = LAST_POINTER_POS.get_or_init(|| Mutex::new(None)).lock() { + *stored = Some(pos); + } + } +} + +fn client_size(hwnd: HWND) -> Option<(u32, u32)> { + let mut rect = RECT::default(); + if let Err(err) = unsafe { GetClientRect(hwnd, &mut rect) } { + warn!("egui overlay: GetClientRect failed: {err}"); + return None; + } + + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || height <= 0 { + return None; + } + + Some((width as u32, height as u32)) +} + +fn find_zoo_window() -> Option { + unsafe { FindWindowA(PCSTR::null(), windows::core::s!("Zoo Tycoon")).ok().filter(|hwnd| !hwnd.0.is_null()) } +} + +fn is_resize_cursor(cursor_icon: CursorIcon) -> bool { + matches!( + cursor_icon, + CursorIcon::ResizeHorizontal + | CursorIcon::ResizeNeSw + | CursorIcon::ResizeNwSe + | CursorIcon::ResizeVertical + | CursorIcon::ResizeEast + | CursorIcon::ResizeSouthEast + | CursorIcon::ResizeSouth + | CursorIcon::ResizeSouthWest + | CursorIcon::ResizeWest + | CursorIcon::ResizeNorthWest + | CursorIcon::ResizeNorth + | CursorIcon::ResizeNorthEast + | CursorIcon::ResizeColumn + | CursorIcon::ResizeRow + ) +} + +fn register_shortcuts() { + crate::shortcut!("egui-overlay", "Toggle egui debug window", U + Ctrl + Shift, false, || { + let visible = !SHOW_DEBUG_WINDOW.load(Ordering::Acquire); + SHOW_DEBUG_WINDOW.store(visible, Ordering::Release); + info!("egui overlay: debug window {}", if visible { "shown" } else { "hidden" }); + }); + + crate::shortcut!("egui-overlay", "Toggle vanilla UI overlay", V + Ctrl + Shift, false, || { + let visible = !SHOW_VANILLA_UI.load(Ordering::Acquire); + SHOW_VANILLA_UI.store(visible, Ordering::Release); + info!("egui overlay: vanilla UI overlay {}", if visible { "shown" } else { "hidden" }); + }); +} + +fn show_debug_panel(ctx: &egui::Context) { + egui::Area::new("openzt_debug_panel".into()) + .order(egui::Order::Foreground) + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-16.0, 72.0)) + .interactable(true) + .show(ctx, |ui| { + let panel_size = Vec2::new(180.0, 38.0); + let (rect, _) = ui.allocate_exact_size(panel_size, Sense::hover()); + let visuals = ui.visuals(); + ui.painter().rect_filled(rect, 0.0, visuals.panel_fill); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, visuals.window_stroke.color), StrokeKind::Inside); + + let inner_rect = rect.shrink(8.0); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner_rect), |ui| { + let mut show_vanilla_ui = SHOW_VANILLA_UI.load(Ordering::Acquire); + if ui.checkbox(&mut show_vanilla_ui, "Show vanilla UI").changed() { + SHOW_VANILLA_UI.store(show_vanilla_ui, Ordering::Release); + } + }); + }); +} + +fn debug_panel_rect(screen_size: Vec2) -> egui::Rect { + egui::Rect::from_min_size(Pos2::new((screen_size.x - 196.0).max(0.0), 72.0), Vec2::new(180.0, 38.0)) +} diff --git a/openzt/src/ui/money_display.rs b/openzt/src/ui/money_display.rs new file mode 100644 index 0000000..2b97a82 --- /dev/null +++ b/openzt/src/ui/money_display.rs @@ -0,0 +1,90 @@ +use std::sync::{Mutex, OnceLock}; + +use openzt_detour::generated::ztui_main::SET_MONEY_TEXT; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(SET_MONEY_TEXT)] + unsafe extern "stdcall" fn ztui_main_set_money_text() { + unsafe { SET_MONEY_TEXT_DETOUR.call() }; + crate::ui::vanilla_main::set_money_value(crate::globals::globals().ztgamemgr().cash()); + } +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized ZTUI::main::setMoneyText detour"), + Err(err) => error!("egui overlay: failed to initialize money display detour: {err}"), + } +} + +#[derive(Clone)] +pub struct MoneyDisplay { + pub text: String, + pub zero: bool, + pub negative: bool, +} + +impl Default for MoneyDisplay { + fn default() -> Self { + Self { + text: "$50,000".to_string(), + zero: false, + negative: false, + } + } +} + +static MONEY_DISPLAY: OnceLock> = OnceLock::new(); + +pub fn set_value(value: f32) { + if let Ok(mut display) = MONEY_DISPLAY.get_or_init(|| Mutex::new(MoneyDisplay::default())).lock() { + display.text = format_money(value); + display.zero = value == 0.0; + display.negative = value < 0.0; + } +} + +pub fn current() -> MoneyDisplay { + MONEY_DISPLAY + .get_or_init(|| Mutex::new(MoneyDisplay::default())) + .lock() + .map(|display| display.clone()) + .unwrap_or_default() +} + +fn format_money(value: f32) -> String { + let rounded = value.round() as i64; + let negative = rounded < 0; + let digits = rounded.abs().to_string(); + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + + for (index, ch) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); + } + + let grouped: String = grouped.chars().rev().collect(); + if negative { + format!("-${grouped}") + } else { + format!("${grouped}") + } +} + +#[cfg(test)] +mod tests { + use super::format_money; + + #[test] + fn formats_money_with_grouping() { + assert_eq!(format_money(50000.0), "$50,000"); + assert_eq!(format_money(-1234.0), "-$1,234"); + } +} diff --git a/openzt/src/ui/pause.rs b/openzt/src/ui/pause.rs new file mode 100644 index 0000000..c61577f --- /dev/null +++ b/openzt/src/ui/pause.rs @@ -0,0 +1,49 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use openzt_detour::generated::ztui_main::{PAUSE_GAME, UNPAUSE_GAME}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +static GAME_PAUSED: AtomicBool = AtomicBool::new(false); + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(PAUSE_GAME)] + unsafe extern "stdcall" fn ztui_main_pause_game() { + unsafe { PAUSE_GAME_DETOUR.call() }; + set_paused(true); + } + + #[detour(UNPAUSE_GAME)] + unsafe extern "stdcall" fn ztui_main_unpause_game() { + unsafe { UNPAUSE_GAME_DETOUR.call() }; + set_paused(false); + } +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized ZTUI::main pause detours"), + Err(err) => error!("egui overlay: failed to initialize pause detours: {err}"), + } +} + +pub fn is_paused() -> bool { + GAME_PAUSED.load(Ordering::Acquire) +} + +pub fn set_paused(paused: bool) { + GAME_PAUSED.store(paused, Ordering::Release); +} + +pub fn click_toggle_pause() { + unsafe { + if is_paused() { + UNPAUSE_GAME.original()(); + } else { + PAUSE_GAME.original()(); + } + } +} diff --git a/openzt/src/ui/render_hook.rs b/openzt/src/ui/render_hook.rs new file mode 100644 index 0000000..f8d31e7 --- /dev/null +++ b/openzt/src/ui/render_hook.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use openzt_detour::generated::gxvideomanager::{FLIP, FLIP_TO_GDI}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +static RENDERING: AtomicBool = AtomicBool::new(false); +static FLIP_CALLS: AtomicU32 = AtomicU32::new(0); +static FLIP_TO_GDI_CALLS: AtomicU32 = AtomicU32::new(0); + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(FLIP)] + unsafe extern "fastcall" fn gxvideomanager_flip(this: *const i32) -> bool { + let result = unsafe { FLIP_DETOUR.call(this) }; + render_overlay("flip", &FLIP_CALLS); + result + } + + #[detour(FLIP_TO_GDI)] + unsafe extern "cdecl" fn gxvideomanager_flip_to_gdi(param: i8) { + unsafe { FLIP_TO_GDI_DETOUR.call(param) }; + render_overlay("flip_to_gdi", &FLIP_TO_GDI_CALLS); + } +} + +fn render_overlay(hook_name: &str, counter: &AtomicU32) { + let count = counter.fetch_add(1, Ordering::Relaxed) + 1; + if count == 1 || count % 300 == 0 { + info!("egui overlay: {hook_name} render hook called {count} times"); + } + + if RENDERING.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed).is_err() { + return; + } + + if let Some(hwnd) = crate::ui::captured_hwnd() { + crate::ui::render_and_blit(hwnd); + } + + RENDERING.store(false, Ordering::Release); +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized GXVideoManager::flip detour"), + Err(err) => error!("egui overlay: failed to initialize render detour: {err}"), + } +} diff --git a/openzt/src/ui/status_display.rs b/openzt/src/ui/status_display.rs new file mode 100644 index 0000000..3bb70df --- /dev/null +++ b/openzt/src/ui/status_display.rs @@ -0,0 +1,69 @@ +use std::sync::atomic::{AtomicI32, Ordering}; + +use openzt_detour::generated::ztui_main::{SET_ANIMAL_RATING, SET_GUEST_RATING, SET_ZOO_RATING}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +const DEFAULT_RATING: i32 = 75; + +static ANIMAL_RATING: AtomicI32 = AtomicI32::new(DEFAULT_RATING); +static GUEST_RATING: AtomicI32 = AtomicI32::new(DEFAULT_RATING); +static ZOO_RATING: AtomicI32 = AtomicI32::new(DEFAULT_RATING); + +#[detour_mod] +mod hooks { + use super::*; + + #[detour(SET_ANIMAL_RATING)] + unsafe extern "cdecl" fn ztui_main_set_animal_rating(value: i32) { + unsafe { SET_ANIMAL_RATING_DETOUR.call(value) }; + set_animal_rating(value); + } + + #[detour(SET_GUEST_RATING)] + unsafe extern "cdecl" fn ztui_main_set_guest_rating(value: i32) { + unsafe { SET_GUEST_RATING_DETOUR.call(value) }; + set_guest_rating(value); + } + + #[detour(SET_ZOO_RATING)] + unsafe extern "cdecl" fn ztui_main_set_zoo_rating(value: i32) { + unsafe { SET_ZOO_RATING_DETOUR.call(value) }; + set_zoo_rating(value); + } +} + +pub fn init() { + match unsafe { hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized ZTUI::main rating detours"), + Err(err) => error!("egui overlay: failed to initialize rating detours: {err}"), + } +} + +pub fn set_animal_rating(value: i32) { + ANIMAL_RATING.store(value, Ordering::Release); +} + +pub fn set_guest_rating(value: i32) { + GUEST_RATING.store(value, Ordering::Release); +} + +pub fn set_zoo_rating(value: i32) { + ZOO_RATING.store(value, Ordering::Release); +} + +pub fn animal_rating() -> u8 { + rating_for_display(ANIMAL_RATING.load(Ordering::Acquire)) +} + +pub fn guest_rating() -> u8 { + rating_for_display(GUEST_RATING.load(Ordering::Acquire)) +} + +pub fn zoo_rating() -> u8 { + rating_for_display(ZOO_RATING.load(Ordering::Acquire)) +} + +fn rating_for_display(value: i32) -> u8 { + value.clamp(0, 100) as u8 +} diff --git a/openzt/src/ui/tga.rs b/openzt/src/ui/tga.rs new file mode 100644 index 0000000..c7a537f --- /dev/null +++ b/openzt/src/ui/tga.rs @@ -0,0 +1,242 @@ +use anyhow::{Context, anyhow, bail, ensure}; +use egui::{Color32, ColorImage}; + +#[derive(Debug, Clone, Copy)] +struct Header { + id_length: usize, + color_map_type: u8, + image_type: u8, + color_map_first_entry: u16, + color_map_length: usize, + color_map_entry_depth: u8, + width: usize, + height: usize, + pixel_depth: u8, + image_descriptor: u8, +} + +pub fn decode_tga(data: &[u8]) -> anyhow::Result { + ensure!(data.len() >= 18, "TGA data is too short"); + + let header = Header { + id_length: data[0] as usize, + color_map_type: data[1], + image_type: data[2], + color_map_first_entry: u16::from_le_bytes([data[3], data[4]]), + color_map_length: u16::from_le_bytes([data[5], data[6]]) as usize, + color_map_entry_depth: data[7], + width: u16::from_le_bytes([data[12], data[13]]) as usize, + height: u16::from_le_bytes([data[14], data[15]]) as usize, + pixel_depth: data[16], + image_descriptor: data[17], + }; + + ensure!(header.width > 0 && header.height > 0, "TGA dimensions must be non-zero"); + + let mut index = 18usize.checked_add(header.id_length).ok_or_else(|| anyhow!("TGA header offset overflow"))?; + ensure!(index <= data.len(), "TGA image ID exceeds data length"); + + let color_map = read_color_map(data, &mut index, header)?; + let pixel_count = header.width * header.height; + let mut source_pixels = Vec::with_capacity(pixel_count); + + match header.image_type { + 1 | 2 | 3 => { + for _ in 0..pixel_count { + source_pixels.push(read_pixel(data, &mut index, header, color_map.as_deref())?); + } + } + 9..=11 => { + while source_pixels.len() < pixel_count { + let packet = read_u8(data, &mut index).context("TGA RLE packet is truncated")?; + let count = (packet & 0x7f) as usize + 1; + ensure!(source_pixels.len() + count <= pixel_count, "TGA RLE packet exceeds image bounds"); + + if packet & 0x80 != 0 { + let pixel = read_pixel(data, &mut index, header, color_map.as_deref())?; + source_pixels.extend(std::iter::repeat(pixel).take(count)); + } else { + for _ in 0..count { + source_pixels.push(read_pixel(data, &mut index, header, color_map.as_deref())?); + } + } + } + } + _ => bail!("unsupported TGA image type {}", header.image_type), + } + + let mut pixels = vec![Color32::TRANSPARENT; pixel_count]; + let top_origin = header.image_descriptor & 0x20 != 0; + let right_origin = header.image_descriptor & 0x10 != 0; + + for y in 0..header.height { + for x in 0..header.width { + let source_index = y * header.width + x; + let dest_x = if right_origin { header.width - 1 - x } else { x }; + let dest_y = if top_origin { y } else { header.height - 1 - y }; + pixels[dest_y * header.width + dest_x] = source_pixels[source_index]; + } + } + + Ok(ColorImage::new([header.width, header.height], pixels)) +} + +fn read_color_map(data: &[u8], index: &mut usize, header: Header) -> anyhow::Result>> { + if header.color_map_type == 0 { + if matches!(header.image_type, 1 | 9) { + bail!("TGA uses indexed pixels but has no embedded color map"); + } + return Ok(None); + } + + ensure!(header.color_map_type == 1, "unsupported TGA color map type {}", header.color_map_type); + ensure!(matches!(header.image_type, 1 | 9), "TGA includes an unused color map"); + + let bytes_per_entry = bytes_per_pixel(header.color_map_entry_depth).context("unsupported TGA color map entry depth")?; + let total_bytes = header + .color_map_length + .checked_mul(bytes_per_entry) + .ok_or_else(|| anyhow!("TGA color map size overflow"))?; + ensure!(*index + total_bytes <= data.len(), "TGA color map is truncated"); + + let mut colors = Vec::with_capacity(header.color_map_length); + for _ in 0..header.color_map_length { + colors.push(read_color(data, index, header.color_map_entry_depth)?); + } + + Ok(Some(colors)) +} + +fn read_pixel(data: &[u8], index: &mut usize, header: Header, color_map: Option<&[Color32]>) -> anyhow::Result { + match header.image_type { + 1 | 9 => { + let raw_index = match header.pixel_depth { + 8 => read_u8(data, index)? as u16, + 16 => read_u16(data, index)?, + _ => bail!("unsupported indexed TGA pixel depth {}", header.pixel_depth), + }; + let palette_index = raw_index + .checked_sub(header.color_map_first_entry) + .ok_or_else(|| anyhow!("TGA palette index {} is before first entry {}", raw_index, header.color_map_first_entry))? + as usize; + color_map + .and_then(|colors| colors.get(palette_index).copied()) + .ok_or_else(|| anyhow!("TGA palette index {} is out of bounds", raw_index)) + } + 2 | 10 => read_color(data, index, header.pixel_depth), + 3 | 11 => match header.pixel_depth { + 8 => { + let value = read_u8(data, index)?; + Ok(Color32::from_gray(value)) + } + _ => bail!("unsupported grayscale TGA pixel depth {}", header.pixel_depth), + }, + _ => bail!("unsupported TGA image type {}", header.image_type), + } +} + +fn read_color(data: &[u8], index: &mut usize, pixel_depth: u8) -> anyhow::Result { + match pixel_depth { + 15 | 16 => { + let value = read_u16(data, index)?; + let b = ((value & 0x1f) as u8) << 3; + let g = (((value >> 5) & 0x1f) as u8) << 3; + let r = (((value >> 10) & 0x1f) as u8) << 3; + let a = if pixel_depth == 16 && value & 0x8000 == 0 { 0 } else { 255 }; + Ok(Color32::from_rgba_unmultiplied(r, g, b, a)) + } + 24 => { + let b = read_u8(data, index)?; + let g = read_u8(data, index)?; + let r = read_u8(data, index)?; + Ok(Color32::from_rgb(r, g, b)) + } + 32 => { + let b = read_u8(data, index)?; + let g = read_u8(data, index)?; + let r = read_u8(data, index)?; + let a = read_u8(data, index)?; + Ok(Color32::from_rgba_unmultiplied(r, g, b, a)) + } + _ => bail!("unsupported TGA color depth {}", pixel_depth), + } +} + +fn bytes_per_pixel(pixel_depth: u8) -> anyhow::Result { + match pixel_depth { + 8 => Ok(1), + 15 | 16 => Ok(2), + 24 => Ok(3), + 32 => Ok(4), + _ => bail!("unsupported pixel depth {}", pixel_depth), + } +} + +fn read_u8(data: &[u8], index: &mut usize) -> anyhow::Result { + let value = *data.get(*index).ok_or_else(|| anyhow!("unexpected end of TGA data"))?; + *index += 1; + Ok(value) +} + +fn read_u16(data: &[u8], index: &mut usize) -> anyhow::Result { + let lo = read_u8(data, index)?; + let hi = read_u8(data, index)?; + Ok(u16::from_le_bytes([lo, hi])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(image_type: u8, width: u16, height: u16, pixel_depth: u8, descriptor: u8) -> Vec { + let mut data = vec![0; 18]; + data[2] = image_type; + data[12..14].copy_from_slice(&width.to_le_bytes()); + data[14..16].copy_from_slice(&height.to_le_bytes()); + data[16] = pixel_depth; + data[17] = descriptor; + data + } + + #[test] + fn decodes_uncompressed_24_bit_true_color() { + let mut data = header(2, 2, 2, 24, 0x20); + data.extend_from_slice(&[0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255]); + + let image = decode_tga(&data).unwrap(); + + assert_eq!(image.size, [2, 2]); + assert_eq!(image.pixels[0], Color32::RED); + assert_eq!(image.pixels[1], Color32::GREEN); + assert_eq!(image.pixels[2], Color32::BLUE); + assert_eq!(image.pixels[3], Color32::WHITE); + } + + #[test] + fn decodes_uncompressed_32_bit_true_color_with_alpha() { + let mut data = header(2, 1, 1, 32, 0x20); + data.extend_from_slice(&[20, 30, 40, 128]); + + let image = decode_tga(&data).unwrap(); + + assert_eq!(image.pixels[0], Color32::from_rgba_unmultiplied(40, 30, 20, 128)); + } + + #[test] + fn flips_bottom_left_origin_to_egui_top_left() { + let mut data = header(2, 1, 2, 24, 0); + data.extend_from_slice(&[255, 0, 0, 0, 0, 255]); + + let image = decode_tga(&data).unwrap(); + + assert_eq!(image.pixels[0], Color32::RED); + assert_eq!(image.pixels[1], Color32::BLUE); + } + + #[test] + fn rejects_unsupported_image_type() { + let data = header(0, 1, 1, 24, 0); + + assert!(decode_tga(&data).is_err()); + } +} diff --git a/openzt/src/ui/tooltip.rs b/openzt/src/ui/tooltip.rs new file mode 100644 index 0000000..accd3a9 --- /dev/null +++ b/openzt/src/ui/tooltip.rs @@ -0,0 +1,335 @@ +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use egui::{Color32, Context, FontId, Frame, Margin, Pos2, RichText, Stroke, Vec2, pos2, vec2}; +use openzt_detour::generated::bfuimgr::{DISPLAY_HELP_0, DISPLAY_HELP_1}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info, warn}; + +use crate::util::get_string_from_memory_bounded; + +const DEFAULT_TOOLTIP_DELAY_MS: u64 = 500; +const DEFAULT_TOOLTIP_DURATION_MS: u64 = 10_000; +const MAX_TOOLTIP_CHARS: usize = 512; +const MAX_TOOLTIP_TEXT_BYTES: u32 = 2048; +const MAX_INLINE_STRING_BYTES: u32 = 4096; +const LONG_TOOLTIP_ID_OFFSET: u32 = 30_000; + +static TOOLTIP: OnceLock> = OnceLock::new(); + +#[derive(Default)] +struct TooltipState { + current: Option, +} + +struct TooltipRequest { + text: String, + requested_at: Instant, + anchor: Option, + delay: Duration, + duration: Duration, + allow_blocked_pointer: bool, +} + +#[detour_mod] +mod tooltip_hooks { + use super::*; + + #[detour(DISPLAY_HELP_1)] + unsafe extern "thiscall" fn bfuimgr_display_help_1(this: *const u32, help_text: *const i32) { + if !crate::ui::is_live_game_active() { + unsafe { DISPLAY_HELP_1_DETOUR.call(this, help_text) }; + return; + } + + if crate::ui::blocks_pointer_input() { + clear_tooltip(); + return; + } + + match tooltip_text(help_text) { + Some(text) => { + set_tooltip(text, false); + } + None => { + clear_tooltip(); + } + } + } + + #[detour(DISPLAY_HELP_0)] + unsafe extern "thiscall" fn bfuimgr_display_help_0(this: *const u32, help_id: i32, has_long_tooltip: bool) { + if !crate::ui::is_live_game_active() { + unsafe { DISPLAY_HELP_0_DETOUR.call(this, help_id, has_long_tooltip) }; + return; + } + + if crate::ui::blocks_pointer_input() { + clear_tooltip(); + return; + } + + match tooltip_text_from_id(help_id, has_long_tooltip as i8) { + Some(text) => set_tooltip(text, false), + None => clear_tooltip(), + } + } +} + +pub fn init() { + match unsafe { tooltip_hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized tooltip detours"), + Err(err) => error!("egui overlay: failed to initialize tooltip detours: {err}"), + } +} + +pub fn show(ctx: &Context, screen_size: Vec2) { + let now = Instant::now(); + let Some((text, anchor)) = active_tooltip(ctx, now) else { + return; + }; + + let max_width = screen_size.x.min(360.0).max(160.0); + let pos = tooltip_pos(anchor, screen_size, max_width); + + egui::Area::new("openzt_tooltip".into()) + .order(egui::Order::Tooltip) + .fixed_pos(pos) + .interactable(false) + .show(ctx, |ui| { + ui.set_max_width(max_width); + Frame::new() + .fill(Color32::from_rgb(0x75, 0x69, 0x3a)) + .stroke(Stroke::new(1.0, Color32::from_rgb(0xf6, 0xda, 0x78))) + .inner_margin(Margin::symmetric(7, 5)) + .show(ui, |ui| { + ui.label(RichText::new(text).font(FontId::proportional(13.0)).color(Color32::WHITE)); + }); + }); +} + +fn active_tooltip(ctx: &Context, now: Instant) -> Option<(String, Pos2)> { + let egui_pointer = ctx.pointer_hover_pos(); + let hwnd_pointer = crate::ui::current_pointer_pos(); + let cached_pointer = crate::ui::last_pointer_pos(); + let pointer = egui_pointer.or(hwnd_pointer).or(cached_pointer); + let mut state = tooltip_state().lock().ok()?; + + let request = state.current.as_mut()?; + + if pointer.is_some_and(crate::ui::blocks_pointer_input_at) && !request.allow_blocked_pointer { + state.current = None; + return None; + } + + if let Some(pointer) = pointer { + request.anchor = Some(pointer); + } + + let elapsed = now.saturating_duration_since(request.requested_at); + if elapsed < request.delay { + ctx.request_repaint_after(request.delay - elapsed); + return None; + } + + let visible_for = elapsed - request.delay; + if visible_for > request.duration { + state.current = None; + return None; + } + + ctx.request_repaint_after(Duration::from_millis(100)); + let anchor = request.anchor.or(pointer).unwrap_or(pos2(24.0, 24.0)); + Some((request.text.clone(), anchor)) +} + +pub(crate) fn set_overlay_help_tooltip(help_id: i32) { + match tooltip_text_from_id(help_id, 1) { + Some(text) => set_tooltip(text, true), + None => clear_tooltip(), + } +} + +pub(crate) fn set_overlay_tooltip(text: String) { + set_tooltip(text, true); +} + +fn set_tooltip(text: String, allow_blocked_pointer: bool) { + let text = text.trim(); + if text.is_empty() { + clear_tooltip(); + return; + } + + let text = truncate_tooltip(text); + let mut state = match tooltip_state().lock() { + Ok(state) => state, + Err(err) => { + error!("egui overlay: tooltip state lock poisoned: {err}"); + return; + } + }; + + let now = Instant::now(); + if let Some(current) = &mut state.current { + if current.text == text { + current.duration = tooltip_duration(); + return; + } + } + + state.current = Some(TooltipRequest { + text, + requested_at: now, + anchor: crate::ui::current_pointer_pos().or_else(crate::ui::last_pointer_pos), + delay: tooltip_delay(), + duration: tooltip_duration(), + allow_blocked_pointer, + }); +} + +pub(crate) fn clear_tooltip() { + if let Ok(mut state) = tooltip_state().lock() { + state.current = None; + } +} + +fn tooltip_state() -> &'static Mutex { + TOOLTIP.get_or_init(|| Mutex::new(TooltipState::default())) +} + +fn tooltip_pos(anchor: Pos2, screen_size: Vec2, _max_width: f32) -> Pos2 { + let offset = vec2(16.0, 20.0); + let x = (anchor.x + offset.x).max(8.0); + let y = (anchor.y + offset.y).max(8.0); + pos2(x.min(screen_size.x - 8.0).max(8.0), y.min(screen_size.y - 8.0).max(8.0)) +} + +fn tooltip_delay() -> Duration { + Duration::from_millis(read_tooltip_setting("tooltipDelay", DEFAULT_TOOLTIP_DELAY_MS)) +} + +fn tooltip_duration() -> Duration { + Duration::from_millis(read_tooltip_setting("tooltipDuration", DEFAULT_TOOLTIP_DURATION_MS)) +} + +fn read_tooltip_setting(key: &str, default_ms: u64) -> u64 { + for section in crate::settings::zoo_setting_sections() { + match crate::settings::get_zoo_setting_i32(§ion, key) { + Ok(Some(value)) if value >= 0 => return value as u64, + Ok(Some(value)) => { + warn!("egui overlay: ignoring negative {key} value {value} in zoo.ini section [{section}]"); + return default_ms; + } + Ok(None) => {} + Err(err) => { + warn!("egui overlay: ignoring invalid {key} value in zoo.ini section [{section}]: {err}"); + return default_ms; + } + } + } + default_ms +} + +pub(crate) fn long_tooltips_enabled() -> bool { + match crate::settings::get_zoo_setting_i32("UI", "helpType") { + Ok(Some(1)) => true, + Ok(Some(_)) | Ok(None) => false, + Err(err) => { + warn!("egui overlay: ignoring invalid helpType value in zoo.ini section [UI]: {err}"); + false + } + } +} + +pub(crate) fn tooltip_text_from_id(help_id: i32, has_long_tooltip: i8) -> Option { + let string_id = tooltip_string_id(help_id, has_long_tooltip, long_tooltips_enabled())?; + crate::string_registry::load_string_by_id(string_id).and_then(|text| { + let text = text.trim().to_string(); + if text.is_empty() { None } else { Some(text) } + }) +} + +fn tooltip_string_id(help_id: i32, has_long_tooltip: i8, long_tooltips_enabled: bool) -> Option { + let help_id = u32::try_from(help_id).ok()?; + if help_id == 0 { + return None; + } + + if long_tooltips_enabled && has_long_tooltip == 1 { + help_id.checked_add(LONG_TOOLTIP_ID_OFFSET) + } else { + Some(help_id) + } +} + +fn tooltip_text(help_text: *const i32) -> Option { + let address = help_text as u32; + if address == 0 { + return None; + } + + let text = read_zt_string(address).unwrap_or_else(|| { + get_string_from_memory_bounded( + address, + address.saturating_add(MAX_TOOLTIP_TEXT_BYTES), + address.saturating_add(MAX_TOOLTIP_TEXT_BYTES), + ) + }); + let text = text.trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} + +fn read_zt_string(address: u32) -> Option { + let start = crate::util::get_from_memory::(address); + let end = crate::util::get_from_memory::(address.saturating_add(4)); + let buffer_end = crate::util::get_from_memory::(address.saturating_add(8)); + + if !plausible_string_range(start, end, buffer_end) { + return None; + } + + let text = get_string_from_memory_bounded(start, end, buffer_end); + if text.is_empty() { None } else { Some(text) } +} + +fn plausible_string_range(start: u32, end: u32, buffer_end: u32) -> bool { + start != 0 && end >= start && buffer_end >= end && end - start <= MAX_TOOLTIP_TEXT_BYTES && buffer_end - start <= MAX_INLINE_STRING_BYTES +} + +fn truncate_tooltip(text: &str) -> String { + if text.chars().count() <= MAX_TOOLTIP_CHARS { + return text.to_string(); + } + + let mut truncated = text.chars().take(MAX_TOOLTIP_CHARS.saturating_sub(3)).collect::(); + truncated.push_str("..."); + truncated +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_help_0_uses_short_id_without_long_setting() { + assert_eq!(tooltip_string_id(3383, 1, false), Some(3383)); + } + + #[test] + fn display_help_0_uses_short_id_without_exact_long_flag() { + assert_eq!(tooltip_string_id(3383, 2, true), Some(3383)); + assert_eq!(tooltip_string_id(3383, 0, true), Some(3383)); + } + + #[test] + fn display_help_0_uses_long_id_only_when_setting_and_flag_match() { + assert_eq!(tooltip_string_id(3383, 1, true), Some(33383)); + } + + #[test] + fn display_help_0_zero_or_negative_id_clears_tooltip() { + assert_eq!(tooltip_string_id(0, 1, true), None); + assert_eq!(tooltip_string_id(-1, 1, true), None); + } +} diff --git a/openzt/src/ui/vanilla_main.rs b/openzt/src/ui/vanilla_main.rs new file mode 100644 index 0000000..8389132 --- /dev/null +++ b/openzt/src/ui/vanilla_main.rs @@ -0,0 +1,2982 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; + +use egui::{ + pos2, text::LayoutJob, vec2, Align, Align2, Color32, ColorImage, Context, FontData, FontDefinitions, FontFamily, FontId, Painter, PointerButton, Pos2, + Rect, Stroke, StrokeKind, TextureHandle, Ui, Vec2, +}; +use openzt_configparser::ini::Ini; +use tracing::{info, warn}; + +use crate::animation::Animation; + +use super::{tga, zt_image}; + +static TEXTURES: OnceLock> = OnceLock::new(); +static BUTTONS: OnceLock> = OnceLock::new(); +static HIT_REGIONS: OnceLock>> = OnceLock::new(); +static BOLD_FONT_REGISTERED: AtomicBool = AtomicBool::new(false); +static BOLD_FONT_ACTIVE: AtomicBool = AtomicBool::new(false); +static HELP_HOVERED_THIS_FRAME: AtomicBool = AtomicBool::new(false); +static HELP_HOVERED_LAST_FRAME: AtomicBool = AtomicBool::new(false); +static OPTIONS_MENU_VISIBLE: AtomicBool = AtomicBool::new(false); +static OPTIONS_MENU_TAB: OnceLock> = OnceLock::new(); +static RESEARCH_PANEL_VISIBLE: AtomicBool = AtomicBool::new(false); +static RESEARCH_PANEL_TAB: OnceLock> = OnceLock::new(); +static RESEARCH_PANEL_STATE: OnceLock> = OnceLock::new(); +/// Runtime-discovered research icon paths (e.g. `research/strain2/strain2`) leaked to `&'static str` +/// once each, so they can be used as `TextureCache`/`ButtonState` keys like every other resource path +/// in this file. See `leaked_icon_resource`. +static RESEARCH_ICON_RESOURCES: OnceLock>> = OnceLock::new(); +static RESEARCH_XPAC_MENU_OPEN: AtomicBool = AtomicBool::new(false); +/// `None` = no filter ("All"); `Some(id)` = only show categories whose `expansion_id()` matches one +/// of `research_expansion_options()`'s ids. Shared across the Research/Conservation tabs since +/// expansions are zoo-wide, not per-branch. +static RESEARCH_EXPANSION_FILTER: OnceLock>> = OnceLock::new(); + +/// Every registered expansion (vanilla retail ones from `xpac0N.cfg` *and* any OpenZT custom +/// expansions from mod config - see `expansions::get_expansions`), as `(category_filter_id, +/// display_name)` pairs for the `[Xpac]` dropdown. `expansions::Expansion::expansion_id()` is +/// `ZTResearchCategory::expansion_id() + 1` for the matching expansion (`parse_expansion_config` +/// stores the raw `.cfg` id `+1`; see `ZTResearchCategory`'s own doc comment on the same offset), so +/// it's converted back here to match what categories actually store. Registry id 0 (the synthetic +/// "All" bucket `initialise_expansions` always adds) is skipped since our own dropdown already has +/// its own "All" entry that clears the filter. `load_string_by_id` resolves the display name for +/// both vanilla (plain string-table id) and custom (OpenZT-registered, `>= 100_000`) expansions +/// uniformly - it already checks the OpenZT registry first. +fn research_expansion_options() -> Vec<(i32, String)> { + crate::expansions::get_expansions() + .into_iter() + .filter(|expansion| expansion.expansion_id() != 0) + .filter_map(|expansion| { + let name = crate::string_registry::load_string_by_id(expansion.name_id())?; + Some((expansion.expansion_id() as i32 - 1, name)) + }) + .collect() +} + +/// "All" + one row per `research_expansion_options()` entry. The popup grows to fit however many +/// expansions are actually registered (vanilla resizes its own dropdown the same way for custom +/// expansions - see `expansions::resize_expansion_dropdown`), rather than assuming a fixed count. +/// Row height matches the width used by `draw_research_xpac_row`'s `row_rect.shrink2(vec2(4.0, 0.0))`. +const RESEARCH_XPAC_TEXT_WRAP_WIDTH: f32 = 123.0 - 2.0 * 4.0; + +/// Per-row height for the "All" row plus each `research_expansion_options()` entry, computed once so +/// popup sizing (`research_xpac_popup_rect`) and row drawing (`draw_research_xpac_menu`) can never +/// disagree - see `measure_row_height`. +fn research_xpac_row_heights(ctx: &Context) -> Vec { + std::iter::once(measure_row_height(ctx, "All", bold_font(12.0), RESEARCH_XPAC_TEXT_WRAP_WIDTH)) + .chain( + research_expansion_options() + .into_iter() + .map(|(_, name)| measure_row_height(ctx, &name, bold_font(12.0), RESEARCH_XPAC_TEXT_WRAP_WIDTH)), + ) + .collect() +} + +fn research_xpac_popup_rect(ctx: &Context, origin: Pos2) -> Rect { + let row_heights = research_xpac_row_heights(ctx); + let height = 2.0 + row_heights.iter().sum::() + 2.0; + Rect::from_min_size(origin + vec2(51.0, 33.0), vec2(127.0, height)) +} + +/// Approximate area of the `[Xpac]` toggle button, used only to exclude it from the "click outside +/// closes the popup" check below - the button's own press/release cycle already handles opening and +/// closing it, so treating a press there as "outside" would race with that and could immediately +/// reopen the popup it just closed (or vice versa). +fn research_xpac_button_rect(origin: Pos2) -> Rect { + Rect::from_min_size(origin + vec2(34.0, 27.0), vec2(127.0, 20.0)) +} + +/// Whether the pointer is currently over the (open) expansion popup - used to stop clicks/hover from +/// passing through to whatever the popup visually covers underneath (the category list's top rows, +/// the list's up-scroll-arrow), since those widgets each do their own independent pointer-position +/// check and have no built-in notion of "something modal is drawn on top of me". +fn research_xpac_popup_blocks_pointer(ctx: &Context, origin: Pos2) -> bool { + RESEARCH_XPAC_MENU_OPEN.load(Ordering::Acquire) && ctx.pointer_hover_pos().is_some_and(|pos| research_xpac_popup_rect(ctx, origin).contains(pos)) +} + +const BOLD_FONT_FAMILY: &str = "zt-bold"; +const BOLD_FONT_NAME: &str = "arial-bold"; +const BOLD_FONT_PATH: &str = r"C:\Windows\Fonts\arialbd.ttf"; +const GREEN_TEXT: Color32 = Color32::from_rgb(83, 219, 83); +const ZERO_MONEY_TEXT: Color32 = Color32::from_rgb(0xf6, 0xd2, 0x5b); +const NEGATIVE_MONEY_TEXT: Color32 = Color32::from_rgb(0xac, 0x4d, 0x2d); +const OPTIONS_TITLE_TEXT: Color32 = Color32::from_rgb(255, 228, 173); +const OPTIONS_SUBTITLE_TEXT: Color32 = Color32::from_rgb(206, 206, 206); +const OPTIONS_LABEL_TEXT: Color32 = Color32::from_rgb(237, 228, 173); +const OPTIONS_BUTTON_TEXT: Color32 = Color32::from_rgb(255, 186, 16); +const OPTIONS_BUTTON_HOVER_TEXT: Color32 = Color32::from_rgb(255, 223, 41); +/// `resrch1.lyt`'s `ResearchProgram`/`ConservationProgram` and `resrch2.lyt`'s `CategoryName`/list +/// row `forecolor` (156, 205, 183). +const RESEARCH_TEXT_TEAL: Color32 = Color32::from_rgb(156, 205, 183); +/// `resrch2.lyt`'s `ProgramName`/`NoItemText` `forecolor` (253, 218, 88). +const RESEARCH_TEXT_GOLD: Color32 = Color32::from_rgb(253, 218, 88); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum OptionsTab { + Main, + GraphicsSound, + Help, + Advanced, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ResearchTab { + Status, + Research, + Conservation, +} + +/// Index of the "Research" and "Conservation" branches within `ZTResearchMgr::branches()`, per +/// `research.cfg`'s `[branches]` list order (`branch=research/branres.cfg` then +/// `branch=research/brancon.cfg`). Verify against the live game with the `list_research()` Lua +/// console command if the panel ever shows swapped branch data. +const RESEARCH_BRANCH_INDEX: usize = 0; +const CONSERVATION_BRANCH_INDEX: usize = 1; + +/// Row heights used to lay out the category list box; `resrch2.lyt`'s `UIListBox` doesn't specify an +/// explicit row height (the vanilla engine derives it from font metrics + `miniconwidth`), so these +/// are reasonable approximations to verify visually in-game. Which one a given row uses is decided +/// per-row by `measure_row_height`, based on whether its name actually wraps to one or two lines. +/// Single-line height is tall enough for the list's 22x18 checkbox plus a little padding. +const RESEARCH_LIST_ROW_HEIGHT_SINGLE: f32 = 22.0; +/// Tall enough for a two-line wrapped name. +const RESEARCH_LIST_ROW_HEIGHT_DOUBLE: f32 = 32.0; + +/// String ids found via the live game's string table (not present in any `.lyt`/decompile in this +/// repo), used as fallback/tooltip text for the research panel's dynamic fields. +const RESEARCH_STRING_NOT_FUNDED: u32 = 23030; +const RESEARCH_STRING_NO_PROGRAM: u32 = 23031; +const RESEARCH_STRING_NO_CATEGORY: u32 = 23032; +const RESEARCH_STRING_DONE_IN_ONE_DAY: u32 = 4026; +const RESEARCH_STRING_DONE_IN_DAYS: u32 = 4028; + +/// Per-branch state for the Research/Conservation list tab (`resrch2.lyt`). Both tabs share the same +/// layout but operate on different branches, so each needs independent scroll/press state. Category +/// checked-state is *not* tracked here - it lives on `ZTResearchCategory::is_enabled()` itself (see +/// `set_enabled`), and the displayed category/program name/progress mirror the branch's actual +/// active research (`current_category()`/`current_program()`), same as the Status tab. +#[derive(Default, Clone, Copy)] +struct ResearchListState { + scroll_offset: f32, + pressed_row: Option, + thumb_dragging: bool, +} + +#[derive(Default)] +struct ResearchPanelState { + research_list: ResearchListState, + conservation_list: ResearchListState, +} + +/// Fixed `resrch1.lyt` y-offsets for one branch-summary block (title/program-name/progress-bar/icon). +/// Research and Conservation blocks are visually identical apart from these offsets and the title text id. +struct ResearchStatusBlockLayout { + title_text_id: u32, + title_y: f32, + program_y: f32, + progress_bg_y: f32, + progress_fill_y: f32, + icon_y: f32, + border_y: f32, +} + +const RESEARCH_STATUS_BLOCK: ResearchStatusBlockLayout = ResearchStatusBlockLayout { + title_text_id: 4040, + title_y: 45.0, + program_y: 65.0, + progress_bg_y: 203.0, + progress_fill_y: 205.0, + icon_y: 116.0, + border_y: 115.0, +}; + +const CONSERVATION_STATUS_BLOCK: ResearchStatusBlockLayout = ResearchStatusBlockLayout { + title_text_id: 4039, + title_y: 234.0, + program_y: 255.0, + progress_bg_y: 393.0, + progress_fill_y: 395.0, + icon_y: 310.0, + border_y: 309.0, +}; + +pub fn set_money_value(value: f32) { + super::money_display::set_value(value); +} + +pub fn set_date_value(value: nt_time::time::UtcDateTime) { + super::date_display::set_value(value); +} + +pub fn blocks_pointer_at(pos: Pos2, screen_size: Vec2) -> bool { + if let Ok(regions) = HIT_REGIONS.get_or_init(|| Mutex::new(Vec::new())).lock() + && !regions.is_empty() + { + return regions.iter().rev().any(|region| region.blocks(pos)); + } + + fallback_main_ui_block_rects(screen_size, |rect| rect.contains(pos)) +} + +struct TextureCache { + animations: HashMap, + tgas: HashMap<&'static str, CachedTgaTexture>, +} + +impl TextureCache { + fn new() -> Self { + Self { + animations: HashMap::new(), + tgas: HashMap::new(), + } + } + + fn animation(&mut self, ctx: &Context, base: &'static str, visual_state: VisualState) -> Option { + let key = TextureKey { base, visual_state }; + let entry = self.animations.entry(key).or_default(); + entry.texture(ctx, base, visual_state) + } + + fn tga(&mut self, ctx: &Context, resource: &'static str) -> Option { + let entry = self.tgas.entry(resource).or_default(); + entry.texture(ctx, resource) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct TextureKey { + base: &'static str, + visual_state: VisualState, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum VisualState { + Normal, + Hover, + Selected, + Disabled, +} + +impl VisualState { + fn animation_name(self) -> &'static str { + match self { + Self::Normal => "N", + Self::Hover => "H", + Self::Selected => "S", + Self::Disabled => "G", + } + } +} + +#[derive(Default)] +struct ButtonState { + selected: HashSet<&'static str>, + pressed: HashSet<&'static str>, +} + +#[derive(Clone, Copy)] +enum ButtonMode { + Momentary, + Selected, +} + +#[derive(Default)] +struct CachedTexture { + texture: Option, + size: Vec2, + offset: Vec2, + mask: Option>, + failed: bool, + missing_logged: bool, +} + +#[derive(Default)] +struct CachedTgaTexture { + texture: Option, + size: Vec2, + missing_logged: bool, +} + +impl CachedTexture { + fn texture(&mut self, ctx: &Context, base: &'static str, visual_state: VisualState) -> Option { + if let Some(texture) = &self.texture { + return Some(LoadedTexture { + texture: texture.clone(), + size: self.size, + offset: self.offset, + mask: self.mask.clone()?, + }); + } + + if self.failed { + return None; + } + + let Some(texture) = load_animation_texture(ctx, base, visual_state, &mut self.missing_logged) else { + return None; + }; + + self.size = texture.size; + self.offset = texture.offset; + self.texture = Some(texture.texture.clone()); + self.mask = Some(texture.mask.clone()); + Some(texture) + } +} + +impl CachedTgaTexture { + fn texture(&mut self, ctx: &Context, resource: &'static str) -> Option { + if let Some(texture) = &self.texture { + return Some(LoadedTgaTexture { + texture: texture.clone(), + size: self.size, + }); + } + + let Some(texture) = load_tga_texture(ctx, resource, &mut self.missing_logged) else { + return None; + }; + + self.size = texture.size; + self.texture = Some(texture.texture.clone()); + Some(texture) + } +} + +#[derive(Clone)] +struct LoadedTexture { + texture: TextureHandle, + size: Vec2, + offset: Vec2, + mask: Arc, +} + +#[derive(Clone)] +struct LoadedTgaTexture { + texture: TextureHandle, + size: Vec2, +} + +#[derive(Clone, Copy)] +struct DrawnRect { + rect: Rect, + loaded: bool, +} + +#[derive(Clone)] +struct HitRegion { + rect: Rect, + uv: Rect, + mask: Arc, +} + +impl HitRegion { + fn blocks(&self, pos: Pos2) -> bool { + if !self.rect.contains(pos) || self.rect.width() <= 0.0 || self.rect.height() <= 0.0 { + return false; + } + + let local_x = (pos.x - self.rect.left()) / self.rect.width(); + let local_y = (pos.y - self.rect.top()) / self.rect.height(); + let u = self.uv.left() + local_x * self.uv.width(); + let v = self.uv.top() + local_y * self.uv.height(); + self.mask.blocks_uv(u, v) + } +} + +#[derive(Clone)] +struct HitMask { + width: usize, + height: usize, + alpha: Vec, +} + +impl HitMask { + fn from_image(image: &ColorImage) -> Self { + Self { + width: image.size[0], + height: image.size[1], + alpha: image.pixels.iter().map(|pixel| pixel.a() > 0).collect(), + } + } + + fn blocks_uv(&self, u: f32, v: f32) -> bool { + if self.width == 0 || self.height == 0 || !u.is_finite() || !v.is_finite() { + return false; + } + + let x = (u.clamp(0.0, 1.0) * self.width as f32).floor() as usize; + let y = (v.clamp(0.0, 1.0) * self.height as f32).floor() as usize; + let x = x.min(self.width - 1); + let y = y.min(self.height - 1); + self.alpha.get(y * self.width + x).copied().unwrap_or(false) + } +} + +pub fn show(ctx: &Context, screen_size: Vec2) { + prepare_bold_font(ctx); + + egui::Area::new("openzt_vanilla_main_ui".into()) + .order(egui::Order::Background) + .fixed_pos(Pos2::ZERO) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(screen_size); + let painter = ui.painter().clone(); + let cache = TEXTURES.get_or_init(|| Mutex::new(TextureCache::new())); + let mut cache = match cache.lock() { + Ok(cache) => cache, + Err(err) => { + warn!("egui overlay: vanilla UI texture cache lock poisoned: {err}"); + return; + } + }; + + let buttons = BUTTONS.get_or_init(|| Mutex::new(ButtonState::default())); + let mut buttons = match buttons.lock() { + Ok(buttons) => buttons, + Err(err) => { + warn!("egui overlay: vanilla UI button state lock poisoned: {err}"); + return; + } + }; + + let mut hit_regions = Vec::new(); + draw_main_ui(ctx, ui, &painter, &mut cache, &mut buttons, &mut hit_regions, screen_size); + remember_hit_regions(hit_regions); + }); +} + +fn draw_main_ui( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + screen_size: Vec2, +) { + HELP_HOVERED_THIS_FRAME.store(false, Ordering::Release); + + let bg1_size = texture_size(ctx, cache, "ui/main/backgnd1/backgnd1", VisualState::Normal).unwrap_or(vec2(64.0, 248.0)); + let bg2_size = texture_size(ctx, cache, "ui/main/backgnd2/backgnd2", VisualState::Normal).unwrap_or(vec2(170.0, 128.0)); + let bg3_size = texture_size(ctx, cache, "ui/main/backgnd3/backgnd3", VisualState::Normal).unwrap_or(vec2(200.0, 112.0)); + let bg4_size = texture_size(ctx, cache, "ui/main/backgnd4/backgnd4", VisualState::Normal).unwrap_or(vec2(330.0, 38.0)); + let bg5_size = texture_size(ctx, cache, "ui/main/backgnd5/backgnd5", VisualState::Normal).unwrap_or(vec2(256.0, 38.0)); + + let bg1_rect = rect_from_pos_size(pos2(0.0, 0.0), bg1_size); + let bg2_pos = pos2(0.0, (screen_size.y - bg2_size.y).max(0.0)); + let bg3_pos = pos2(0.0, (screen_size.y - bg3_size.y).max(0.0)); + let bg4_pos = pos2(((screen_size.x - bg4_size.x) * 0.5).max(0.0), (screen_size.y - bg4_size.y).max(0.0)); + let bg5_pos = pos2((screen_size.x - bg5_size.x).max(0.0), (screen_size.y - bg5_size.y).max(0.0)); + + let bg2 = rect_from_pos_size(bg2_pos, bg2_size); + let bg3 = rect_from_pos_size(bg3_pos, bg3_size); + let bg4 = rect_from_pos_size(bg4_pos, bg4_size); + let bg5 = rect_from_pos_size(bg5_pos, bg5_size); + + if bg2.top() > bg1_rect.bottom() { + draw_tiled_y(ctx, painter, cache, hit_regions, "ui/main/bg2/bg2", pos2(0.0, bg1_rect.bottom()), bg2.top() - bg1_rect.bottom()); + } + if bg4.left() > bg3.right() { + draw_tiled_x_bottom(ctx, painter, cache, hit_regions, "ui/main/bg3/bg3", bg3.right(), screen_size.y, bg4.left() - bg3.right()); + } + if bg5.left() > bg4.right() { + draw_tiled_x_bottom(ctx, painter, cache, hit_regions, "ui/main/bg4/bg4", bg4.right(), screen_size.y, bg5.left() - bg4.right()); + } + + draw_options_menu(ctx, ui, painter, cache, buttons, hit_regions); + draw_research_panel(ctx, painter, cache, buttons, hit_regions); + + draw_anim(ctx, painter, cache, hit_regions, "ui/main/backgnd4/backgnd4", bg4_pos, bg4_size); + let bg1 = draw_anim(ctx, painter, cache, hit_regions, "ui/main/backgnd1/backgnd1", pos2(0.0, 0.0), bg1_size); + draw_anim(ctx, painter, cache, hit_regions, "ui/main/backgnd2/backgnd2", bg2_pos, bg2_size); + draw_anim(ctx, painter, cache, hit_regions, "ui/main/backgnd3/backgnd3", bg3_pos, bg3_size); + draw_anim(ctx, painter, cache, hit_regions, "ui/main/backgnd5/backgnd5", bg5_pos, bg5_size); + + draw_left_buttons(ctx, ui, painter, cache, buttons, hit_regions, bg1.rect); + draw_minimap_cluster(ctx, ui, painter, cache, buttons, hit_regions, bg2); + draw_time_and_money(ctx, ui, painter, cache, buttons, hit_regions, bg3, bg4); + draw_status_cluster(ctx, ui, painter, cache, buttons, hit_regions, bg4, bg5); + + finish_help_tooltip_frame(); +} + +fn remember_hit_regions(hit_regions: Vec) { + if let Ok(mut stored) = HIT_REGIONS.get_or_init(|| Mutex::new(Vec::new())).lock() { + *stored = hit_regions; + } +} + +fn fallback_main_ui_block_rects(screen_size: Vec2, mut visit: impl FnMut(Rect) -> bool) -> bool { + let bg1 = Rect::from_min_size(pos2(0.0, 0.0), vec2(64.0, 248.0)); + let bg2 = Rect::from_min_size(pos2(0.0, (screen_size.y - 128.0).max(0.0)), vec2(170.0, 128.0)); + let bg3 = Rect::from_min_size(pos2(0.0, (screen_size.y - 112.0).max(0.0)), vec2(200.0, 112.0)); + let bg4 = Rect::from_min_size(pos2(((screen_size.x - 330.0) * 0.5).max(0.0), (screen_size.y - 38.0).max(0.0)), vec2(330.0, 38.0)); + let bg5 = Rect::from_min_size(pos2((screen_size.x - 256.0).max(0.0), (screen_size.y - 38.0).max(0.0)), vec2(256.0, 38.0)); + + if [bg1, bg2, bg3, bg4, bg5].into_iter().any(&mut visit) { + return true; + } + if bg2.top() > bg1.bottom() { + if visit(Rect::from_min_max(pos2(0.0, bg1.bottom()), pos2(64.0, bg2.top()))) { + return true; + } + } + if bg4.left() > bg3.right() { + if visit(Rect::from_min_max( + pos2(bg3.right(), (screen_size.y - 112.0).max(0.0)), + pos2(bg4.left(), screen_size.y), + )) { + return true; + } + } + if bg5.left() > bg4.right() { + if visit(Rect::from_min_max( + pos2(bg4.right(), (screen_size.y - 38.0).max(0.0)), + pos2(bg5.left(), screen_size.y), + )) { + return true; + } + } + + false +} + +fn draw_left_buttons( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons_state: &mut ButtonState, + hit_regions: &mut Vec, + bg1: Rect, +) { + let buttons = [ + ("ui/main/habitat/habitat", 4.0, 13.0), + ("ui/main/buyanim/buyanim", 4.0, 60.0), + ("ui/main/buyobj/buyobj", 4.0, 108.0), + ("ui/main/person/person", 4.0, 154.0), + ("ui/main/undo/undo", 1.0, 258.0), + ("ui/main/bdoz/bdoz", 1.0, 293.0), + ("ui/main/msgs/msgs", 1.0, 328.0), + ("ui/main/resr/resr", 1.0, 363.0), + ("ui/scenario/scenbut/scenbut", 1.0, 398.0), + ("ui/main/gameopt/gameopt", 1.0, 433.0), + ]; + + for (resource, x, y) in buttons { + if resource == "ui/main/gameopt/gameopt" { + let selected = OPTIONS_MENU_VISIBLE.load(Ordering::Acquire); + draw_explicit_button( + ctx, + painter, + cache, + buttons_state, + hit_regions, + "main-options-button", + resource, + bg1.min + vec2(x, y), + vec2(40.0, 40.0), + selected, + true, + Some(toggle_options_menu), + button_help_id(resource), + ); + } else if resource == "ui/main/resr/resr" { + let selected = RESEARCH_PANEL_VISIBLE.load(Ordering::Acquire); + draw_explicit_button( + ctx, + painter, + cache, + buttons_state, + hit_regions, + "main-research-button", + resource, + bg1.min + vec2(x, y), + vec2(40.0, 40.0), + selected, + true, + Some(toggle_research_panel), + button_help_id(resource), + ); + } else { + draw_button( + ctx, + ui, + painter, + cache, + buttons_state, + hit_regions, + resource, + bg1.min + vec2(x, y), + vec2(40.0, 40.0), + ButtonMode::Selected, + ); + } + } +} + +fn draw_options_menu( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, +) { + if !OPTIONS_MENU_VISIBLE.load(Ordering::Acquire) { + return; + } + + let origin = pos2(17.0, 6.0); + draw_anim(ctx, painter, cache, hit_regions, "ui/gameopts/optpanbk/optpanbk", origin, vec2(179.0, 440.0)); + draw_text_id(painter, 1540, Rect::from_min_size(origin + vec2(35.0, 5.0), vec2(100.0, 18.0)), 13.0, OPTIONS_TITLE_TEXT, Align2::LEFT_CENTER); + + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + "options-close", + "ui/sharedui/close/close", + origin + vec2(142.0, 3.0), + vec2(24.0, 24.0), + false, + true, + Some(hide_options_menu), + Some(3112), + ); + + let active_tab = active_options_tab(); + draw_options_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 80.0), + "options-tab-main", + "ui/gameopts/gameopt/gameopt", + OptionsTab::Main, + active_tab, + 1541, + ); + draw_options_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 114.0), + "options-tab-graphics-sound", + "ui/gameopts/setting/setting", + OptionsTab::GraphicsSound, + active_tab, + 1542, + ); + draw_options_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 147.0), + "options-tab-help", + "ui/gameopts/tooltip/tooltip", + OptionsTab::Help, + active_tab, + 1543, + ); + draw_options_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 180.0), + "options-tab-advanced", + "ui/gameopts/advance/advance", + OptionsTab::Advanced, + active_tab, + 1530, + ); + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + "options-about-button", + "ui/gameopts/about/about", + origin + vec2(154.0, 213.0), + vec2(34.0, 34.0), + false, + true, + Some(show_about_help_popup), + Some(1544), + ); + + match active_tab { + OptionsTab::Main => draw_options_main_tab(ctx, painter, cache, buttons, hit_regions, origin), + OptionsTab::GraphicsSound => draw_options_graphics_sound_tab(ctx, painter, cache, buttons, hit_regions, origin), + OptionsTab::Help => draw_options_help_tab(ctx, painter, cache, buttons, hit_regions, origin), + OptionsTab::Advanced => draw_options_advanced_tab(ctx, painter, cache, buttons, hit_regions, origin), + } + + let _ = ui; +} + +fn draw_options_main_tab( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, +) { + draw_options_subtitle(painter, origin, 1541); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-load-game", origin + vec2(32.0, 252.0), 1501, Some(1560), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-save-game", origin + vec2(32.0, 283.0), 1502, Some(1561), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-start-new", origin + vec2(32.0, 314.0), 1503, Some(1562), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-exit-game", origin + vec2(32.0, 345.0), 1504, Some(1563), false); +} + +fn draw_options_graphics_sound_tab( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, +) { + draw_options_subtitle(painter, origin, 1542); + draw_options_label(painter, origin + vec2(38.0, 120.0), vec2(110.0, 16.0), 1510, Align2::LEFT_CENTER); + draw_options_slider(ctx, painter, cache, buttons, hit_regions, "options-main-volume", origin + vec2(38.0, 135.0), 0.65, Some(1564)); + draw_options_label(painter, origin + vec2(38.0, 165.0), vec2(110.0, 16.0), 1512, Align2::LEFT_CENTER); + draw_options_slider(ctx, painter, cache, buttons, hit_regions, "options-menu-music", origin + vec2(38.0, 180.0), 0.45, Some(1565)); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-window-mode", origin + vec2(32.0, 210.0), 1587, Some(1566), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-resolution-800", origin + vec2(32.0, 245.0), 1517, Some(1568), true); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-resolution-1024", origin + vec2(32.0, 275.0), 1518, Some(1569), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-resolution-1280", origin + vec2(32.0, 305.0), 1519, Some(1570), false); + + draw_options_label(painter, origin + vec2(33.0, 334.0), vec2(121.0, 16.0), 1598, Align2::CENTER_CENTER); + draw_radio_row(ctx, painter, cache, buttons, hit_regions, "options-terrain-none", origin + vec2(36.0, 350.0), 1594, Some(1534), false); + draw_radio_row(ctx, painter, cache, buttons, hit_regions, "options-terrain-speed", origin + vec2(36.0, 367.0), 1595, Some(1535), false); + draw_radio_row(ctx, painter, cache, buttons, hit_regions, "options-terrain-balanced", origin + vec2(36.0, 384.0), 1596, Some(1536), true); + draw_radio_row(ctx, painter, cache, buttons, hit_regions, "options-terrain-quality", origin + vec2(36.0, 401.0), 1597, Some(1537), false); +} + +fn draw_options_help_tab( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, +) { + draw_options_subtitle(painter, origin, 1543); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-tooltip-short", origin + vec2(32.0, 217.0), 1520, Some(1571), true); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-tooltip-long", origin + vec2(32.0, 247.0), 1521, Some(1572), false); + draw_options_label(painter, origin + vec2(38.0, 275.0), vec2(128.0, 16.0), 1522, Align2::LEFT_CENTER); + draw_options_slider(ctx, painter, cache, buttons, hit_regions, "options-tooltip-delay", origin + vec2(38.0, 290.0), 0.35, Some(1573)); + draw_options_label(painter, origin + vec2(38.0, 320.0), vec2(128.0, 16.0), 1524, Align2::LEFT_CENTER); + draw_options_slider(ctx, painter, cache, buttons, hit_regions, "options-tooltip-duration", origin + vec2(38.0, 335.0), 0.65, Some(1574)); + draw_options_label(painter, origin + vec2(38.0, 365.0), vec2(128.0, 16.0), 1514, Align2::LEFT_CENTER); + draw_options_slider(ctx, painter, cache, buttons, hit_regions, "options-main-scroll-speed", origin + vec2(38.0, 380.0), 0.55, Some(1575)); +} + +fn draw_options_advanced_tab( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, +) { + draw_options_subtitle_id(painter, origin, 1530); + draw_options_label(painter, origin + vec2(33.0, 130.0), vec2(120.0, 16.0), 1547, Align2::CENTER_CENTER); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-video-high", origin + vec2(32.0, 147.0), 1539, Some(1557), true); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-video-low", origin + vec2(32.0, 177.0), 1546, Some(1558), false); + + draw_options_label(painter, origin + vec2(33.0, 222.0), vec2(120.0, 16.0), 1548, Align2::CENTER_CENTER); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-sound-high", origin + vec2(32.0, 237.0), 1551, Some(1559), true); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-sound-low", origin + vec2(32.0, 267.0), 1552, Some(1578), false); + + draw_options_label(painter, origin + vec2(33.0, 311.0), vec2(120.0, 16.0), 1553, Align2::CENTER_CENTER); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-mouse-high", origin + vec2(32.0, 327.0), 1555, Some(1579), false); + draw_text_button(ctx, painter, cache, buttons, hit_regions, "options-mouse-low", origin + vec2(32.0, 357.0), 1556, Some(1582), true); +} + +fn draw_options_tab_button( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + pos: Pos2, + key: &'static str, + resource: &'static str, + tab: OptionsTab, + active_tab: OptionsTab, + help_id: i32, +) { + let action = match tab { + OptionsTab::Main => set_options_tab_main, + OptionsTab::GraphicsSound => set_options_tab_graphics_sound, + OptionsTab::Help => set_options_tab_help, + OptionsTab::Advanced => set_options_tab_advanced, + }; + draw_explicit_button(ctx, painter, cache, buttons, hit_regions, key, resource, pos, vec2(34.0, 34.0), tab == active_tab, true, Some(action), Some(help_id)); +} + +fn draw_options_subtitle(painter: &Painter, origin: Pos2, text_id: u32) { + draw_options_subtitle_id(painter, origin, text_id); +} + +fn draw_options_subtitle_id(painter: &Painter, origin: Pos2, text_id: u32) { + draw_text_id( + painter, + text_id, + Rect::from_min_size(origin + vec2(29.0, 29.0), vec2(127.0, 18.0)), + 13.0, + OPTIONS_SUBTITLE_TEXT, + Align2::CENTER_CENTER, + ); +} + +fn draw_options_label(painter: &Painter, pos: Pos2, size: Vec2, text_id: u32, align: Align2) { + draw_text_id(painter, text_id, Rect::from_min_size(pos, size), 12.0, OPTIONS_LABEL_TEXT, align); +} + +fn draw_text_button( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + pos: Pos2, + text_id: u32, + help_id: Option, + selected: bool, +) { + let button = draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + key, + "ui/gameopts/textbck/textbck", + pos, + vec2(124.0, 25.0), + selected, + true, + None, + help_id, + ); + let color = if rect_contains_pointer(ctx, button.rect) { + OPTIONS_BUTTON_HOVER_TEXT + } else { + OPTIONS_BUTTON_TEXT + }; + draw_text_id(painter, text_id, button.rect.shrink2(vec2(8.0, 2.0)), 13.0, color, Align2::CENTER_CENTER); +} + +fn draw_options_slider( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + _buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + pos: Pos2, + value: f32, + help_id: Option, +) { + let track = draw_anim_fixed_size( + ctx, + painter, + cache, + hit_regions, + "ui/sharedui/horzslid/horzslid", + pos, + vec2(110.0, 26.0), + ); + if let Some(help_id) = help_id { + request_help_tooltip_for_rect(ctx, track.rect, help_id); + } + let thumb_size = texture_size(ctx, cache, "ui/sharedui/horzthm/horzthm", VisualState::Normal).unwrap_or(vec2(14.0, 26.0)); + let thumb_x = pos.x + (track.rect.width() - thumb_size.x).max(0.0) * value.clamp(0.0, 1.0); + let thumb_y = pos.y + ((track.rect.height() - thumb_size.y) * 0.5).max(0.0); + draw_anim(ctx, painter, cache, hit_regions, "ui/sharedui/horzthm/horzthm", pos2(thumb_x, thumb_y), vec2(14.0, 26.0)); + let _ = key; +} + +fn draw_radio_row( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + pos: Pos2, + text_id: u32, + help_id: Option, + selected: bool, +) { + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + key, + "ui/sharedui/radio/radio", + pos, + vec2(14.0, 14.0), + selected, + true, + None, + help_id, + ); + let label_rect = Rect::from_min_size(pos + vec2(18.0, -1.0), vec2(95.0, 16.0)); + if let Some(help_id) = help_id { + request_help_tooltip_for_rect(ctx, label_rect, help_id); + } + draw_text_id(painter, text_id, label_rect, 12.0, OPTIONS_LABEL_TEXT, Align2::LEFT_CENTER); +} + +/// Draws `text` wrapped to fit `rect`'s width, up to `max_rows` lines (eliding the last row with +/// `…` via `TextWrapping::overflow_character` if it still doesn't fit), aligned per `halign` and +/// vertically centered within `rect`. Used for research/program/category names, which vanilla's +/// fixed-size text fields are too narrow to reliably show on one line. +fn draw_wrapped_text(ctx: &Context, painter: &Painter, text: &str, rect: Rect, font: FontId, color: Color32, halign: Align, max_rows: usize) { + if text.is_empty() { + return; + } + let mut job = LayoutJob::simple(text.to_string(), font, color, rect.width().max(0.0)); + job.halign = halign; + job.wrap.max_rows = max_rows; + let galley = ctx.fonts_mut(|fonts| fonts.layout_job(job)); + // Without `job.justify`, epaint's halign centers/right-aligns each row around x=0 relative to + // that row's own natural width (not within the wrap box) - so the anchor point must move with + // `halign` rather than always being the rect's left edge. + let pos_x = match halign { + Align::Center => rect.center().x, + Align::Max => rect.right(), + Align::Min => rect.left(), + }; + let pos = pos2(pos_x, rect.center().y - galley.rect.height() / 2.0); + painter.galley(pos, galley, color); +} + +/// Whether `text`, wrapped at `wrap_width` in `font`, needs one line or two - the shared row-height +/// classifier for both the category list and the xpac dropdown, both of which draw their row text +/// through `draw_wrapped_text` with `max_rows = 2` and want each row's own height to match how many +/// lines its name actually took, rather than assuming a uniform height regardless of content. +fn measure_row_height(ctx: &Context, text: &str, font: FontId, wrap_width: f32) -> f32 { + if text.is_empty() { + return RESEARCH_LIST_ROW_HEIGHT_SINGLE; + } + let mut job = LayoutJob::simple(text.to_string(), font, Color32::WHITE, wrap_width.max(0.0)); + job.wrap.max_rows = 2; + let galley = ctx.fonts_mut(|fonts| fonts.layout_job(job)); + if galley.rows.len() <= 1 { + RESEARCH_LIST_ROW_HEIGHT_SINGLE + } else { + RESEARCH_LIST_ROW_HEIGHT_DOUBLE + } +} + +fn draw_text_id(painter: &Painter, text_id: u32, rect: Rect, size: f32, color: Color32, align: Align2) { + let text = crate::string_registry::load_string_by_id(text_id).unwrap_or_else(|| format!("#{text_id}")); + let pos = if align == Align2::LEFT_CENTER { + rect.left_center() + } else if align == Align2::CENTER_CENTER { + rect.center() + } else if align == Align2::RIGHT_CENTER { + rect.right_center() + } else if align == Align2::LEFT_TOP { + rect.left_top() + } else if align == Align2::CENTER_TOP { + pos2(rect.center().x, rect.top()) + } else if align == Align2::RIGHT_TOP { + rect.right_top() + } else if align == Align2::LEFT_BOTTOM { + rect.left_bottom() + } else if align == Align2::CENTER_BOTTOM { + pos2(rect.center().x, rect.bottom()) + } else { + rect.right_bottom() + }; + painter.text(pos, align, text.trim(), bold_font(size), color); +} + +fn active_options_tab() -> OptionsTab { + *OPTIONS_MENU_TAB.get_or_init(|| Mutex::new(OptionsTab::Main)).lock().unwrap() +} + +fn set_active_options_tab(tab: OptionsTab) { + if let Ok(mut active) = OPTIONS_MENU_TAB.get_or_init(|| Mutex::new(OptionsTab::Main)).lock() { + *active = tab; + } +} + +fn toggle_options_menu() { + let visible = !OPTIONS_MENU_VISIBLE.load(Ordering::Acquire); + OPTIONS_MENU_VISIBLE.store(visible, Ordering::Release); + if visible { + set_active_options_tab(OptionsTab::Main); + hide_research_panel(); + } +} + +fn hide_options_menu() { + OPTIONS_MENU_VISIBLE.store(false, Ordering::Release); +} + +fn set_options_tab_main() { + set_active_options_tab(OptionsTab::Main); +} + +fn set_options_tab_graphics_sound() { + set_active_options_tab(OptionsTab::GraphicsSound); +} + +fn set_options_tab_help() { + set_active_options_tab(OptionsTab::Help); +} + +fn set_options_tab_advanced() { + set_active_options_tab(OptionsTab::Advanced); +} + +fn show_about_help_popup() { + super::tooltip::set_overlay_help_tooltip(1544); +} + +// --------------------------------------------------------------------------------------------- +// Research panel (research.lyt / resrch1.lyt / resrch2.lyt) +// --------------------------------------------------------------------------------------------- + +fn active_research_tab() -> ResearchTab { + *RESEARCH_PANEL_TAB.get_or_init(|| Mutex::new(ResearchTab::Status)).lock().unwrap() +} + +fn set_active_research_tab(tab: ResearchTab) { + if let Ok(mut active) = RESEARCH_PANEL_TAB.get_or_init(|| Mutex::new(ResearchTab::Status)).lock() { + *active = tab; + } +} + +fn set_research_tab_status() { + set_active_research_tab(ResearchTab::Status); +} + +fn set_research_tab_research() { + set_active_research_tab(ResearchTab::Research); +} + +fn set_research_tab_conservation() { + set_active_research_tab(ResearchTab::Conservation); +} + +fn toggle_research_panel() { + let visible = !RESEARCH_PANEL_VISIBLE.load(Ordering::Acquire); + RESEARCH_PANEL_VISIBLE.store(visible, Ordering::Release); + if visible { + set_active_research_tab(ResearchTab::Status); + hide_options_menu(); + } +} + +fn hide_research_panel() { + RESEARCH_PANEL_VISIBLE.store(false, Ordering::Release); +} + +fn toggle_research_xpac_menu() { + let open = !RESEARCH_XPAC_MENU_OPEN.load(Ordering::Acquire); + RESEARCH_XPAC_MENU_OPEN.store(open, Ordering::Release); +} + +fn close_research_xpac_menu() { + RESEARCH_XPAC_MENU_OPEN.store(false, Ordering::Release); +} + +fn research_expansion_filter() -> Option { + *RESEARCH_EXPANSION_FILTER.get_or_init(|| Mutex::new(None)).lock().unwrap() +} + +fn clear_research_expansion_filter() { + if let Ok(mut filter) = RESEARCH_EXPANSION_FILTER.get_or_init(|| Mutex::new(None)).lock() { + *filter = None; + } + close_research_xpac_menu(); +} + +fn set_research_expansion_filter(id: i32) { + if let Ok(mut filter) = RESEARCH_EXPANSION_FILTER.get_or_init(|| Mutex::new(None)).lock() { + *filter = Some(id); + } + close_research_xpac_menu(); +} + +fn research_panel_state() -> &'static Mutex { + RESEARCH_PANEL_STATE.get_or_init(|| Mutex::new(ResearchPanelState::default())) +} + +fn research_list_state_mut(branch_index: usize, f: impl FnOnce(&mut ResearchListState) -> R) -> R { + let mut state = research_panel_state().lock().unwrap_or_else(|err| err.into_inner()); + let list_state = if branch_index == CONSERVATION_BRANCH_INDEX { + &mut state.conservation_list + } else { + &mut state.research_list + }; + f(list_state) +} + +fn research_list_state(branch_index: usize) -> ResearchListState { + research_list_state_mut(branch_index, |state| *state) +} + +fn increase_research_branch_funding() { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > RESEARCH_BRANCH_INDEX { + mgr.branch_mut(RESEARCH_BRANCH_INDEX).increase_funding(); + } +} + +fn decrease_research_branch_funding() { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > RESEARCH_BRANCH_INDEX { + mgr.branch_mut(RESEARCH_BRANCH_INDEX).decrease_funding(); + } +} + +fn increase_conservation_branch_funding() { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > CONSERVATION_BRANCH_INDEX { + mgr.branch_mut(CONSERVATION_BRANCH_INDEX).increase_funding(); + } +} + +fn decrease_conservation_branch_funding() { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > CONSERVATION_BRANCH_INDEX { + mgr.branch_mut(CONSERVATION_BRANCH_INDEX).decrease_funding(); + } +} + +fn scroll_research_list_up() { + research_list_state_mut(RESEARCH_BRANCH_INDEX, |state| state.scroll_offset = (state.scroll_offset - RESEARCH_LIST_ROW_HEIGHT_SINGLE).max(0.0)); +} + +fn scroll_research_list_down() { + research_list_state_mut(RESEARCH_BRANCH_INDEX, |state| state.scroll_offset += RESEARCH_LIST_ROW_HEIGHT_SINGLE); +} + +fn scroll_conservation_list_up() { + research_list_state_mut(CONSERVATION_BRANCH_INDEX, |state| state.scroll_offset = (state.scroll_offset - RESEARCH_LIST_ROW_HEIGHT_SINGLE).max(0.0)); +} + +fn scroll_conservation_list_down() { + research_list_state_mut(CONSERVATION_BRANCH_INDEX, |state| state.scroll_offset += RESEARCH_LIST_ROW_HEIGHT_SINGLE); +} + +/// Like `draw_status_meter` (the zoo/animal/guest rating meters) but always fills with +/// `ui/sharedui/statg.tga` regardless of `pct` - research progress bars are a plain green fill at +/// any completion level, not a red/yellow/green health-style meter. +fn draw_research_progress_fill(ctx: &Context, painter: &Painter, cache: &mut TextureCache, meter: Rect, pct: f32) { + let pct = pct.clamp(0.0, 100.0); + let fill_width = (meter.width() * pct / 100.0).round(); + if fill_width <= 0.0 { + return; + } + + let fill = Rect::from_min_size(meter.min, vec2(fill_width, meter.height())); + if let Some(texture) = cache.tga(ctx, "ui/sharedui/statg.tga") { + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((fill_width / texture.size.x).min(1.0), (meter.height() / texture.size.y).min(1.0))); + painter.image(texture.texture.id(), fill, uv, Color32::WHITE); + } else { + painter.rect_filled(fill, 0.0, Color32::from_rgb(66, 196, 59)); + } +} + +fn program_progress_pct(program: &crate::ztresearch::ZTResearchProgram) -> f32 { + let target = program.target_cost(); + if target <= 0.0 { + 0.0 + } else { + (program.current_progress() / target * 100.0).clamp(0.0, 100.0) + } +} + +/// Progress-bar hover tooltip: "Done in %s day(s)" when the branch's current program has active +/// funding, or "Not Funded" when it doesn't (`days_remaining_on_program()` returns `None` for both +/// "no program selected" and "program selected but funding rate is 0" - only show anything for the +/// latter, since there's nothing useful to say about an empty bar). +fn request_research_progress_tooltip(has_program: bool, days_remaining: Option) { + if !has_program { + return; + } + let text = match days_remaining { + Some(days) => { + let whole_days = days.round().max(0.0) as i64; + let template_id = if whole_days == 1 { RESEARCH_STRING_DONE_IN_ONE_DAY } else { RESEARCH_STRING_DONE_IN_DAYS }; + crate::string_registry::load_string_by_id(template_id).map(|template| template.replace("%s", &whole_days.to_string())) + } + None => crate::string_registry::load_string_by_id(RESEARCH_STRING_NOT_FUNDED), + }; + if let Some(text) = text { + request_help_tooltip(text); + } +} + +/// Icon paths (e.g. `research/strain2/strain2`) come from `ZTResearchProgram`/`ZTResearchCategory`/ +/// `ZTResearchBranch` accessors as runtime `String`s, but `TextureCache`/`ButtonState` are keyed by +/// `&'static str` everywhere in this file. The set of distinct research icons is small and fixed for +/// the life of the process (~125 topics), so leaking each newly-seen path once and caching the +/// leaked pointer is simpler than threading owned/`Cow<'static, str>` keys through those APIs. +fn leaked_icon_resource(path: &str) -> &'static str { + let cache = RESEARCH_ICON_RESOURCES.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(existing) = cache.get(path) { + return existing; + } + let leaked: &'static str = Box::leak(path.to_string().into_boxed_str()); + cache.insert(path.to_string(), leaked); + leaked +} + +/// Manual press/release-in-rect tracking for one row of the category list, mirroring +/// `draw_button_impl`'s press-then-release-while-hovered logic but scoped to a local +/// `ResearchListState` instead of the shared `ButtonState`, since list rows are dynamic in count and +/// carry a runtime category id rather than a `&'static str` key. +fn list_row_clicked(ctx: &Context, rect: Rect, row_index: usize, pressed_row: &mut Option) -> bool { + let hovered = rect_contains_pointer(ctx, rect); + let pressed = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)); + let down = ctx.input(|input| input.pointer.button_down(PointerButton::Primary)); + let released = ctx.input(|input| input.pointer.button_released(PointerButton::Primary)); + + if hovered && pressed { + *pressed_row = Some(row_index); + } + + // Only the row that actually captured the press may read/clear `pressed_row` this frame - + // every other row must leave it alone. Without this guard, whichever row happens to iterate + // first each frame (row 0) would unconditionally clear the real presser's state before that + // row's own turn came up, so only row 0 could ever complete a click. + if *pressed_row != Some(row_index) { + return false; + } + + if released { + *pressed_row = None; + return hovered; + } + if !down { + *pressed_row = None; + } + + false +} + +fn draw_research_panel(ctx: &Context, painter: &Painter, cache: &mut TextureCache, buttons: &mut ButtonState, hit_regions: &mut Vec) { + if !RESEARCH_PANEL_VISIBLE.load(Ordering::Acquire) { + return; + } + + let origin = pos2(17.0, 6.0); + let active_tab = active_research_tab(); + let background = if matches!(active_tab, ResearchTab::Status) { + "ui/research/respan/respan" + } else { + "ui/research/resconpn/resconpn" + }; + draw_anim(ctx, painter, cache, hit_regions, background, origin, vec2(179.0, 440.0)); + + // research.lyt's PanelTitle is statically bound to textid=4001 ("Program Status"), but that's + // only correct for the Status tab; switching tabs should retitle the panel to the active + // branch's name, reusing the same text ids resrch1.lyt's block headers use for "Research"/ + // "Conservation" (4040/4039 - also what the tab buttons' own helpid= already points at). + let title_text_id = match active_tab { + ResearchTab::Status => 4001, + ResearchTab::Research => 4040, + ResearchTab::Conservation => 4039, + }; + draw_text_id( + painter, + title_text_id, + Rect::from_min_size(origin + vec2(35.0, 5.0), vec2(120.0, 18.0)), + 13.0, + OPTIONS_TITLE_TEXT, + Align2::LEFT_CENTER, + ); + + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + "research-close", + "ui/sharedui/close/close", + origin + vec2(142.0, 3.0), + vec2(24.0, 24.0), + false, + true, + Some(hide_research_panel), + Some(3112), + ); + + draw_research_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 80.0), + "research-tab-status", + "ui/guest/tbinfo/tbinfo", + ResearchTab::Status, + active_tab, + 4001, + ); + draw_research_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 114.0), + "research-tab-research", + "ui/research/tbreser/tbreser", + ResearchTab::Research, + active_tab, + 4040, + ); + draw_research_tab_button( + ctx, + painter, + cache, + buttons, + hit_regions, + origin + vec2(154.0, 147.0), + "research-tab-conservation", + "ui/research/fund/fund", + ResearchTab::Conservation, + active_tab, + 4039, + ); + + match active_tab { + ResearchTab::Status => draw_research_status_tab(ctx, painter, cache, hit_regions, origin), + ResearchTab::Research => draw_research_list_tab(ctx, painter, cache, buttons, hit_regions, origin, RESEARCH_BRANCH_INDEX), + ResearchTab::Conservation => draw_research_list_tab(ctx, painter, cache, buttons, hit_regions, origin, CONSERVATION_BRANCH_INDEX), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_research_tab_button( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + pos: Pos2, + key: &'static str, + resource: &'static str, + tab: ResearchTab, + active_tab: ResearchTab, + help_id: i32, +) { + let action = match tab { + ResearchTab::Status => set_research_tab_status, + ResearchTab::Research => set_research_tab_research, + ResearchTab::Conservation => set_research_tab_conservation, + }; + draw_explicit_button(ctx, painter, cache, buttons, hit_regions, key, resource, pos, vec2(34.0, 34.0), tab == active_tab, true, Some(action), Some(help_id)); +} + +fn draw_research_status_tab(ctx: &Context, painter: &Painter, cache: &mut TextureCache, hit_regions: &mut Vec, origin: Pos2) { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > RESEARCH_BRANCH_INDEX { + draw_research_branch_status_block(ctx, painter, cache, hit_regions, origin, mgr.branch(RESEARCH_BRANCH_INDEX), &RESEARCH_STATUS_BLOCK); + } + if mgr.branch_count() > CONSERVATION_BRANCH_INDEX { + draw_research_branch_status_block(ctx, painter, cache, hit_regions, origin, mgr.branch(CONSERVATION_BRANCH_INDEX), &CONSERVATION_STATUS_BLOCK); + } +} + +fn draw_research_branch_status_block( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + origin: Pos2, + branch: &crate::ztresearch::ZTResearchBranch, + layout: &ResearchStatusBlockLayout, +) { + draw_text_id( + painter, + layout.title_text_id, + Rect::from_min_size(origin + vec2(38.0, layout.title_y), vec2(110.0, 16.0)), + 13.0, + OPTIONS_SUBTITLE_TEXT, + Align2::CENTER_CENTER, + ); + + let program_rect = Rect::from_min_size(origin + vec2(38.0, layout.program_y), vec2(110.0, 50.0)); + let current_program = branch.current_program(); + let program_name = current_program + .as_ref() + .and_then(|program| program.name()) + .or_else(|| crate::string_registry::load_string_by_id(RESEARCH_STRING_NO_PROGRAM)) + .unwrap_or_default(); + draw_wrapped_text(ctx, painter, &program_name, program_rect, bold_font(13.0), RESEARCH_TEXT_TEAL, Align::Center, 2); + + let progress_bg_rect = Rect::from_min_size(origin + vec2(35.0, layout.progress_bg_y), vec2(119.0, 18.0)); + draw_anim(ctx, painter, cache, hit_regions, "ui/research/progback/progback", progress_bg_rect.min, progress_bg_rect.size()); + if let Some(program) = current_program { + let fill_rect = Rect::from_min_size(origin + vec2(40.0, layout.progress_fill_y), vec2(110.0, 12.0)); + draw_research_progress_fill(ctx, painter, cache, fill_rect, program_progress_pct(program)); + } + if rect_contains_pointer(ctx, progress_bg_rect) { + request_research_progress_tooltip(current_program.is_some(), branch.days_remaining_on_program()); + } + + let icon_resource = current_program + .as_ref() + .and_then(|program| program.icon()) + .or_else(|| branch.noprogicon()) + .map(|icon| leaked_icon_resource(&icon)) + .unwrap_or("ui/research/noprog/noprog"); + let icon_rect = Rect::from_min_size(origin + vec2(72.0, layout.icon_y), vec2(44.0, 32.0)); + draw_anim(ctx, painter, cache, hit_regions, icon_resource, icon_rect.min, icon_rect.size()); + draw_anim( + ctx, + painter, + cache, + hit_regions, + "ui/sharedui/border/border", + origin + vec2(71.0, layout.border_y), + vec2(46.0, 34.0), + ); + if rect_contains_pointer(ctx, icon_rect) + && let Some(program) = current_program + { + request_help_tooltip_for_id(program.help_id()); + } +} + +fn draw_research_list_tab( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, + branch_index: usize, +) { + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() <= branch_index { + return; + } + let branch = mgr.branch(branch_index); + let is_conservation = branch_index == CONSERVATION_BRANCH_INDEX; + + draw_research_xpac_button(ctx, painter, cache, buttons, hit_regions, origin); + + let (minus_key, plus_key, decrease_fn, increase_fn): (&'static str, &'static str, fn(), fn()) = if is_conservation { + ( + "conservation-funding-minus", + "conservation-funding-plus", + decrease_conservation_branch_funding as fn(), + increase_conservation_branch_funding as fn(), + ) + } else { + ( + "research-funding-minus", + "research-funding-plus", + decrease_research_branch_funding as fn(), + increase_research_branch_funding as fn(), + ) + }; + + let funding_level_count = branch.funding_levels().len() as i32; + let current_funding_level = branch.current_funding_level(); + let can_decrease = current_funding_level > 0; + let can_increase = current_funding_level + 1 < funding_level_count; + + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + minus_key, + "ui/research/minus/minus", + origin + vec2(37.0, 259.0), + vec2(30.0, 30.0), + false, + can_decrease, + Some(decrease_fn), + Some(4003), + ); + draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + plus_key, + "ui/research/plus/plus", + origin + vec2(94.0, 259.0), + vec2(30.0, 30.0), + false, + can_increase, + Some(increase_fn), + Some(4004), + ); + + draw_text_id( + painter, + 4005, + Rect::from_min_size(origin + vec2(38.0, 200.0), vec2(110.0, 30.0)), + 12.0, + OPTIONS_SUBTITLE_TEXT, + Align2::CENTER_CENTER, + ); + + // getFundingText's "%s" is filled with the money-formatted cost of the *current* funding level + // (per private/resources/decompiles/ZTResearchBranch_getFundingText.c: builds the name_id template using + // a `bfinternat::getMoneyText`-formatted string, not the branch name), so use the already-correct + // vanilla implementation directly rather than hand-rolling the substitution. + let funding_level_rect = Rect::from_min_size(origin + vec2(38.0, 233.0), vec2(110.0, 30.0)); + draw_wrapped_text(ctx, painter, &branch.funding_text(), funding_level_rect, bold_font(13.0), GREEN_TEXT, Align::Center, 2); + request_help_tooltip_for_rect(ctx, funding_level_rect, 4006); + + // CategoryName/ProgramName/progress/icon mirror the branch's actual active research (same data + // as the Status tab), not a UI-local list selection - the list below is a multi-select checklist + // of which categories are eligible for `pick_random_program`, tracked on the categories + // themselves via `is_enabled()`/`set_enabled`, not "which one is being viewed". + let current_category = branch.current_category(); + let current_program = branch.current_program(); + + let category_name_rect = Rect::from_min_size(origin + vec2(33.0, 303.0), vec2(122.0, 30.0)); + let category_name = current_category + .and_then(|category| category.name()) + .or_else(|| crate::string_registry::load_string_by_id(RESEARCH_STRING_NO_CATEGORY)) + .unwrap_or_default(); + draw_wrapped_text(ctx, painter, &category_name, category_name_rect, bold_font(13.0), RESEARCH_TEXT_TEAL, Align::Center, 2); + if rect_contains_pointer(ctx, category_name_rect) + && let Some(category) = current_category + { + request_help_tooltip_for_id(category.help_id()); + } + + let program_name_rect = Rect::from_min_size(origin + vec2(33.0, 330.0), vec2(122.0, 30.0)); + let program_name = current_program + .and_then(|program| program.name()) + .or_else(|| crate::string_registry::load_string_by_id(RESEARCH_STRING_NO_PROGRAM)) + .unwrap_or_default(); + draw_wrapped_text(ctx, painter, &program_name, program_name_rect, bold_font(13.0), RESEARCH_TEXT_GOLD, Align::Center, 2); + + let progress_bg_rect = Rect::from_min_size(origin + vec2(34.0, 400.0), vec2(119.0, 18.0)); + draw_anim(ctx, painter, cache, hit_regions, "ui/research/progback/progback", progress_bg_rect.min, progress_bg_rect.size()); + if let Some(program) = current_program { + let fill_rect = Rect::from_min_size(origin + vec2(40.0, 402.0), vec2(110.0, 12.0)); + draw_research_progress_fill(ctx, painter, cache, fill_rect, program_progress_pct(program)); + } + if rect_contains_pointer(ctx, progress_bg_rect) { + request_research_progress_tooltip(current_program.is_some(), branch.days_remaining_on_program()); + } + + let icon_resource = current_program + .and_then(|program| program.icon()) + .or_else(|| branch.noprogicon()) + .map(|icon| leaked_icon_resource(&icon)) + .unwrap_or("ui/research/noprog/noprog"); + let icon_rect = Rect::from_min_size(origin + vec2(71.0, 363.0), vec2(44.0, 32.0)); + draw_anim(ctx, painter, cache, hit_regions, icon_resource, icon_rect.min, icon_rect.size()); + draw_anim(ctx, painter, cache, hit_regions, "ui/sharedui/border/border", origin + vec2(70.0, 361.0), vec2(46.0, 34.0)); + if rect_contains_pointer(ctx, icon_rect) + && let Some(program) = current_program + { + request_help_tooltip_for_id(program.help_id()); + } + + let expansion_filter = research_expansion_filter(); + let categories: Vec<_> = branch + .categories() + .filter(|category| expansion_filter.map_or(true, |filter| category.expansion_id() == filter)) + .collect(); + draw_research_category_list(ctx, painter, cache, buttons, hit_regions, origin, branch_index, &categories, is_conservation); + + // Drawn last so the popup renders on top of the list/funding controls it overlaps. + if RESEARCH_XPAC_MENU_OPEN.load(Ordering::Acquire) { + draw_research_xpac_menu(ctx, painter, cache, hit_regions, origin); + } +} + +/// `resrch2.lyt`'s `[Xpac]` button - opens `ui/xpac.lyt`'s expansion filter dropdown. Its label shows +/// the currently selected expansion (or "All"). +fn draw_research_xpac_button(ctx: &Context, painter: &Painter, cache: &mut TextureCache, buttons: &mut ButtonState, hit_regions: &mut Vec, origin: Pos2) { + let button = draw_explicit_button( + ctx, + painter, + cache, + buttons, + hit_regions, + "research-xpac-button", + "ui/sharedui/list/list", + origin + vec2(34.0, 27.0), + vec2(127.0, 20.0), + RESEARCH_XPAC_MENU_OPEN.load(Ordering::Acquire), + true, + Some(toggle_research_xpac_menu), + Some(22999), + ); + + let label = research_expansion_filter() + .and_then(|id| research_expansion_options().into_iter().find(|(expansion_id, _)| *expansion_id == id)) + .map(|(_, name)| name) + .unwrap_or_else(|| "All".to_string()); + let label_color = if rect_contains_pointer(ctx, button.rect) { OPTIONS_BUTTON_HOVER_TEXT } else { OPTIONS_BUTTON_TEXT }; + draw_wrapped_text(ctx, painter, &label, button.rect.shrink2(vec2(6.0, 2.0)), bold_font(12.0), label_color, Align::Center, 1); +} + +/// `ui/xpac.lyt`'s popup: a background (`ui/sharedui/listbk/listbk`) plus an "All" row and one row +/// per `research_expansion_options()` entry. Usually a handful of rows at most, so plain +/// click-on-press detection (no shared `ButtonState`/press-tracking machinery) is enough - unlike +/// the potentially-long dynamic category list. +fn draw_research_xpac_menu(ctx: &Context, painter: &Painter, cache: &mut TextureCache, hit_regions: &mut Vec, origin: Pos2) { + let popup_rect = research_xpac_popup_rect(ctx, origin); + + // A click outside the popup (and outside the toggle button itself - its own press/release cycle + // already opens/closes the menu, so excluding it avoids a same-press race) dismisses it without + // applying any selection. (Escape-to-close was tried and removed: the overlay has no way to stop + // the vanilla game from also processing the same Escape keypress - e.g. it would cancel an active + // build tool - without a WM_CHAR-level fix in wndproc.rs. Revisit that properly if this is wanted + // again; don't reintroduce the GetAsyncKeyState poll alone.) + let clicked_outside = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)) + && ctx + .pointer_hover_pos() + .is_some_and(|pos| !popup_rect.contains(pos) && !research_xpac_button_rect(origin).contains(pos)); + if clicked_outside { + close_research_xpac_menu(); + return; + } + + // draw_anim uses the texture's *native* decoded size once loaded (fallback_size only applies if + // the texture fails to load) - listbk's native size is a fixed 127x68, generally shorter than + // the popup needs, so draw_anim_fixed_size (which always stretches to the given size) is + // required here instead. + draw_anim_fixed_size(ctx, painter, cache, hit_regions, "ui/sharedui/listbk/listbk", popup_rect.min, popup_rect.size()); + + let current_filter = research_expansion_filter(); + let list_origin = popup_rect.min + vec2(2.0, 2.0); + + let row_heights = research_xpac_row_heights(ctx); + let mut row_offsets = Vec::with_capacity(row_heights.len()); + let mut acc = 0.0; + for height in &row_heights { + row_offsets.push(acc); + acc += height; + } + + draw_research_xpac_row(ctx, painter, list_origin, row_offsets[0], row_heights[0], "All", current_filter.is_none(), clear_research_expansion_filter); + + for (i, (expansion_id, name)) in research_expansion_options().into_iter().enumerate() { + let idx = i + 1; + draw_research_xpac_row(ctx, painter, list_origin, row_offsets[idx], row_heights[idx], &name, current_filter == Some(expansion_id), move || { + set_research_expansion_filter(expansion_id) + }); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_research_xpac_row( + ctx: &Context, + painter: &Painter, + list_origin: Pos2, + row_top_offset: f32, + row_height: f32, + name: &str, + selected: bool, + action: impl FnOnce(), +) { + let row_rect = Rect::from_min_size(list_origin + vec2(0.0, row_top_offset), vec2(123.0, row_height)); + let hovered = rect_contains_pointer(ctx, row_rect); + let bg_color = if selected { + Color32::from_rgba_unmultiplied(255, 255, 255, 40) + } else if hovered { + Color32::from_rgba_unmultiplied(156, 205, 183, 25) + } else { + Color32::TRANSPARENT + }; + painter.rect_filled(row_rect, 0.0, bg_color); + draw_wrapped_text(ctx, painter, name, row_rect.shrink2(vec2(4.0, 0.0)), bold_font(12.0), RESEARCH_TEXT_TEAL, Align::LEFT, 2); + + if hovered && ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)) { + action(); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_research_category_list( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + origin: Pos2, + branch_index: usize, + categories: &[&'static crate::ztresearch::ZTResearchCategory], + is_conservation: bool, +) { + let list_rect = Rect::from_min_size(origin + vec2(38.0, 51.0), vec2(102.0, 148.0)); + // The expansion popup overlaps only the top portion of this list (and the up-scroll-arrow) when + // open; gate hover/click on whatever's actually under the pointer right now rather than a blanket + // "list is open" flag, since rows below the popup's bottom edge remain perfectly clickable. + let popup_blocks_pointer = research_xpac_popup_blocks_pointer(ctx, origin); + + if categories.is_empty() { + draw_anim_fixed_size(ctx, painter, cache, hit_regions, "ui/sharedui/scrolbck/scrolbck", origin + vec2(140.0, 51.0), vec2(12.0, 148.0)); + // draw_text_id paints via `Painter::text`, which never wraps - fine for the short fixed + // button/title labels it's used for elsewhere, but this "no categories" message needs to wrap + // within the list box's width, so it goes through `draw_wrapped_text` instead. + if let Some(text) = crate::string_registry::load_string_by_id(11003) { + draw_wrapped_text( + ctx, + painter, + &text, + Rect::from_min_size(origin + vec2(40.0, 51.0), vec2(110.0, 204.0)), + bold_font(12.0), + RESEARCH_TEXT_GOLD, + Align::Center, + 8, + ); + } + return; + } + + // Confirmed via private/resources/decompiles/_fillList.c (OOAnalyzer::UIListBox::addString's icon + // arguments): vanilla uses two distinct single-state icons, ui/sharedui/checkbx1/checkbx1 + // (unchecked) and ui/sharedui/checkbx2/checkbx2 (checked), not a single N/H/S chrome. Both are + // 22x18, matching resrch2.lyt's `miniconwidth = 22` - confirming the list has no separate + // per-row topic icon, the reserved "mini icon" column *is* the checkbox. + const CHECKBOX_SIZE: Vec2 = Vec2 { x: 22.0, y: 18.0 }; + const TEXT_LEFT_PAD: f32 = CHECKBOX_SIZE.x + 3.0; + const TEXT_RIGHT_PAD: f32 = 4.0; + + // Each row's height depends on whether its name wraps to one or two lines (see + // `measure_row_height`), so heights/offsets are precomputed together in one pass rather than + // assumed uniform - this is also what fixes the scrollbar's `max_scroll` overestimating how far + // there is to scroll. + let text_wrap_width = (list_rect.width() - TEXT_LEFT_PAD - TEXT_RIGHT_PAD).max(0.0); + let rows: Vec<(String, f32)> = categories + .iter() + .map(|category| { + let name = category.name().unwrap_or_default(); + let height = measure_row_height(ctx, &name, bold_font(12.0), text_wrap_width); + (name, height) + }) + .collect(); + let mut row_offsets: Vec = Vec::with_capacity(rows.len()); + let mut acc = 0.0; + for (_, height) in &rows { + row_offsets.push(acc); + acc += height; + } + let total_height = acc; + let max_scroll = (total_height - list_rect.height()).max(0.0); + research_list_state_mut(branch_index, |state| state.scroll_offset = state.scroll_offset.clamp(0.0, max_scroll)); + let scroll_offset = research_list_state(branch_index).scroll_offset; + + // Clip all row drawing to the list box so long category names can't bleed past its bounds. + let list_painter = painter.with_clip_rect(list_rect); + + for (row_index, category) in categories.iter().enumerate() { + let (name, row_height) = &rows[row_index]; + let row_top = list_rect.top() + row_offsets[row_index] - scroll_offset; + if row_top + row_height < list_rect.top() || row_top > list_rect.bottom() { + continue; + } + let row_rect = Rect::from_min_size(pos2(list_rect.left(), row_top), vec2(list_rect.width(), *row_height)); + + // Clicking a row toggles that category's own `enabled` flag - this is a multi-select + // checklist (any number of categories can be checked at once), persisted on the category + // itself via `ZTResearchCategory::set_enabled`, not UI-local "which row is selected" state. + let clicked = !popup_blocks_pointer + && research_list_state_mut(branch_index, |state| list_row_clicked(ctx, row_rect, row_index, &mut state.pressed_row)); + if clicked { + let currently_enabled = category.is_enabled(); + let mgr = crate::globals::globals().ztresearchmgr(); + if mgr.branch_count() > branch_index { + mgr.branch_mut(branch_index).category_mut(row_index).set_enabled(!currently_enabled); + } + } + + let enabled = category.is_enabled(); + let hovered = !popup_blocks_pointer && rect_contains_pointer(ctx, row_rect); + if hovered { + request_help_tooltip_for_id(category.help_id()); + } + + let checkbox_pos = pos2(row_rect.left(), row_rect.center().y - CHECKBOX_SIZE.y / 2.0); + let checkbox_resource = if enabled { "ui/sharedui/checkbx2/checkbx2" } else { "ui/sharedui/checkbx1/checkbx1" }; + draw_anim(ctx, &list_painter, cache, hit_regions, checkbox_resource, checkbox_pos, CHECKBOX_SIZE); + + let text_rect = Rect::from_min_size( + pos2(row_rect.left() + TEXT_LEFT_PAD, row_rect.top()), + vec2((row_rect.width() - TEXT_LEFT_PAD - TEXT_RIGHT_PAD).max(0.0), row_rect.height()), + ); + draw_wrapped_text(ctx, &list_painter, name, text_rect, bold_font(12.0), RESEARCH_TEXT_TEAL, Align::LEFT, 2); + + if hovered { + // Matches resrch2.lyt's `highlightbordercolor` (255, 217, 90) - vanilla's list rows don't + // change background or text color on hover/select, only the row border. Drawn last so it + // sits in front of the checkbox/text rather than being covered by them. + list_painter.rect_stroke(row_rect, 0.0, Stroke::new(1.0, Color32::from_rgb(255, 217, 90)), StrokeKind::Inside); + } + } + + // draw_anim uses the texture's *native* decoded size once loaded (the requested size only + // applies as a fallback if the texture fails to load) - scrolbck's native size doesn't match the + // list box's actual height, so draw_anim_fixed_size (which always stretches to the given size) is + // required here instead, or the background visibly overshoots the list box. + draw_anim_fixed_size(ctx, painter, cache, hit_regions, "ui/sharedui/scrolbck/scrolbck", origin + vec2(140.0, 51.0), vec2(12.0, 148.0)); + + let (up_key, down_key, up_fn, down_fn): (&'static str, &'static str, fn(), fn()) = if is_conservation { + ("conservation-list-up", "conservation-list-down", scroll_conservation_list_up as fn(), scroll_conservation_list_down as fn()) + } else { + ("research-list-up", "research-list-down", scroll_research_list_up as fn(), scroll_research_list_down as fn()) + }; + // Fixed-size (not `draw_explicit_button`, which renders at the texture's native decoded size): + // these arrows are anchored to line up exactly with the list box's top/bottom edges and the + // scrollbar track, so a native-size mismatch would make them overshoot past those edges. + draw_explicit_button_fixed_size( + ctx, + painter, + cache, + buttons, + hit_regions, + up_key, + "ui/sharedui/arowup/arowup", + origin + vec2(140.0, 51.0), + vec2(12.0, 12.0), + false, + scroll_offset > 0.0 && !popup_blocks_pointer, + Some(up_fn), + None, + ); + draw_explicit_button_fixed_size( + ctx, + painter, + cache, + buttons, + hit_regions, + down_key, + "ui/sharedui/arowdn/arowdn", + origin + vec2(140.0, 187.0), + vec2(12.0, 12.0), + false, + scroll_offset < max_scroll, + Some(down_fn), + None, + ); + + if max_scroll > 0.0 { + let track_height = (list_rect.height() - 24.0).max(0.0); + let thumb_height = 20.0_f32.min(track_height.max(1.0)); + let thumb_travel = (track_height - thumb_height).max(0.0); + let thumb_y = list_rect.top() + 12.0 + (scroll_offset / max_scroll) * thumb_travel; + // draw_anim_fixed_size (not draw_anim) so the thumb actually renders at the computed + // `thumb_height` instead of the texture's native decoded size, which could otherwise spill + // past the down arrow regardless of how far there is left to scroll. + let thumb_drawn = draw_anim_fixed_size(ctx, painter, cache, hit_regions, "ui/sharedui/thumb/thumb", pos2(origin.x + 140.0, thumb_y), vec2(12.0, thumb_height)); + + // The thumb is otherwise purely decorative (`draw_anim_fixed_size` only paints it and + // registers a `HitRegion` for tooltip/pass-through purposes) - drag it directly to scroll, + // proportional to how far it's moved along its travel range. No drag-start anchor is needed: `pointer.delta()` + // already gives this frame's movement, so it accumulates onto `scroll_offset` naturally. + let thumb_hovered = !popup_blocks_pointer && rect_contains_pointer(ctx, thumb_drawn.rect); + let pressed = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)); + let down = ctx.input(|input| input.pointer.button_down(PointerButton::Primary)); + + research_list_state_mut(branch_index, |state| { + if thumb_hovered && pressed { + state.thumb_dragging = true; + } + if !down { + state.thumb_dragging = false; + } + if state.thumb_dragging && thumb_travel > 0.0 { + let delta_y = ctx.input(|input| input.pointer.delta().y); + if delta_y != 0.0 { + state.scroll_offset = (state.scroll_offset + delta_y / thumb_travel * max_scroll).clamp(0.0, max_scroll); + } + } + }); + } +} + +fn draw_minimap_cluster( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + bg2: Rect, +) { + let zoom_level = crate::globals::globals().ztworldmgr().zoom_level(); + + draw_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/sharedui/snap/snap", + bg2.min + vec2(5.0, 86.0), + vec2(34.0, 34.0), + ButtonMode::Momentary, + crate::ztui::click_snapshot, + ); + draw_enabled_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/zoomin/zoomin", + bg2.min + vec2(14.0, 17.0), + vec2(28.0, 28.0), + ButtonMode::Momentary, + zoom_level < 2, + crate::ztui::click_zoom_in, + ); + draw_enabled_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/zoomout/zoomout", + bg2.min + vec2(5.0, 24.0), + vec2(28.0, 28.0), + ButtonMode::Momentary, + zoom_level > -2, + crate::ztui::click_zoom_out, + ); + draw_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/rotr/rotr", + bg2.min + vec2(6.0, 40.0), + vec2(28.0, 28.0), + ButtonMode::Momentary, + crate::ztui::click_rotate_cw, + ); + draw_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/rotl/rotl", + bg2.min + vec2(26.0, 27.0), + vec2(28.0, 28.0), + ButtonMode::Momentary, + crate::ztui::click_rotate_ccw, + ); + draw_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/trees/trees", + bg2.min + vec2(147.0, 81.0), + vec2(28.0, 28.0), + ButtonMode::Selected, + ); + draw_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/guests/guests", + bg2.min + vec2(127.0, 90.0), + vec2(28.0, 28.0), + ButtonMode::Selected, + ); + draw_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/builds/builds", + bg2.min + vec2(106.0, 100.0), + vec2(28.0, 28.0), + ButtonMode::Selected, + ); + + let _minimap = Rect::from_min_size(bg2.min + vec2(10.0, 44.0), vec2(139.0, 69.0)); + request_help_tooltip_for_rect(ctx, Rect::from_min_size(bg2.min + vec2(10.0, 44.0), vec2(139.0, 69.0)), 1026); +} + +fn draw_time_and_money( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + bg3: Rect, + bg4: Rect, +) { + let pause_resource = if super::pause::is_paused() { + "ui/main/play/play" + } else { + "ui/main/pause/pause" + }; + let pause = draw_action_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + pause_resource, + bg3.min + vec2(170.0, 80.0), + vec2(34.0, 34.0), + ButtonMode::Momentary, + super::pause::click_toggle_pause, + ); + let date_rect = Rect::from_min_size(pause.rect.min + vec2(25.0, 7.0), vec2(108.0, 18.0)); + painter.text(date_rect.center(), Align2::CENTER_CENTER, super::date_display::current(), bold_font(14.0), GREEN_TEXT); + if rect_contains_pointer(ctx, date_rect) + && let Some(text) = super::date_display::tooltip_text() + { + request_help_tooltip(text); + } + + let money_rect = Rect::from_min_size(bg4.min + vec2(90.0, 9.0), vec2(125.0, 18.0)); + let money = super::money_display::current(); + let money_color = if money.negative { + NEGATIVE_MONEY_TEXT + } else if money.zero { + ZERO_MONEY_TEXT + } else { + GREEN_TEXT + }; + painter.text(money_rect.center(), Align2::CENTER_CENTER, money.text, bold_font(14.0), money_color); + request_help_tooltip_for_rect(ctx, money_rect, 1016); +} + +fn draw_status_cluster( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + bg4: Rect, + bg5: Rect, +) { + draw_status(ctx, ui, painter, cache, buttons, hit_regions, "ui/main/zstat/zstat", bg4.min + vec2(231.0, 3.0), true); + draw_status(ctx, ui, painter, cache, buttons, hit_regions, "ui/main/astat/astat", bg5.min + vec2(0.0, 3.0), true); + draw_status(ctx, ui, painter, cache, buttons, hit_regions, "ui/main/gstat/gstat", bg5.min + vec2(85.0, 3.0), true); + draw_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/hstat/hstat", + bg5.min + vec2(170.0, 3.0), + vec2(34.0, 34.0), + ButtonMode::Selected, + ); + draw_button( + ctx, + ui, + painter, + cache, + buttons, + hit_regions, + "ui/main/staff/staff", + bg5.min + vec2(206.0, 3.0), + vec2(34.0, 34.0), + ButtonMode::Selected, + ); +} + +fn draw_status( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + button: &'static str, + pos: Pos2, + with_meter: bool, +) { + if with_meter { + draw_anim(ctx, painter, cache, hit_regions, "ui/main/progbck/progbck", pos + vec2(26.0, 5.0), vec2(56.0, 22.0)); + let meter = Rect::from_min_size(pos + vec2(32.0, 9.0), vec2(45.0, 13.0)); + draw_status_meter(ctx, painter, cache, meter, status_meter_value(button)); + if rect_contains_pointer(ctx, meter) + && let Some(text) = status_meter_tooltip(button) + { + request_help_tooltip(text); + } + } + draw_button(ctx, ui, painter, cache, buttons, hit_regions, button, pos, vec2(34.0, 34.0), ButtonMode::Selected); +} + +fn draw_status_meter(ctx: &Context, painter: &Painter, cache: &mut TextureCache, meter: Rect, value: u8) { + let value = value.min(100); + let fill_width = (meter.width() * value as f32 / 100.0).round(); + if fill_width <= 0.0 { + return; + } + + let resource = status_meter_texture(value); + let fill = Rect::from_min_size(meter.min, vec2(fill_width, meter.height())); + if let Some(texture) = cache.tga(ctx, resource) { + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((fill_width / texture.size.x).min(1.0), (meter.height() / texture.size.y).min(1.0))); + painter.image(texture.texture.id(), fill, uv, Color32::WHITE); + } else { + painter.rect_filled(fill, 0.0, status_meter_fallback_color(value)); + } +} + +fn status_meter_texture(value: u8) -> &'static str { + match value { + 0..=24 => "ui/sharedui/statr.tga", + 25..=49 => "ui/sharedui/staty.tga", + _ => "ui/sharedui/statg.tga", + } +} + +fn status_meter_fallback_color(value: u8) -> Color32 { + match value { + 0..=24 => Color32::from_rgb(0xac, 0x4d, 0x2d), + 25..=49 => Color32::from_rgb(0xf6, 0xd2, 0x5b), + _ => Color32::from_rgb(66, 196, 59), + } +} + +fn status_meter_value(resource: &str) -> u8 { + match resource { + "ui/main/zstat/zstat" => super::status_display::zoo_rating(), + "ui/main/astat/astat" => super::status_display::animal_rating(), + "ui/main/gstat/gstat" => super::status_display::guest_rating(), + _ => 75, + } +} + +fn draw_button( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + mode: ButtonMode, +) -> DrawnRect { + draw_button_impl(ctx, ui, painter, cache, buttons, hit_regions, resource, resource, pos, fallback_size, mode, true, None) +} + +fn draw_action_button( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + mode: ButtonMode, + action: fn(), +) -> DrawnRect { + draw_button_impl(ctx, ui, painter, cache, buttons, hit_regions, resource, resource, pos, fallback_size, mode, true, Some(action)) +} + +fn draw_enabled_action_button( + ctx: &Context, + ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + mode: ButtonMode, + enabled: bool, + action: fn(), +) -> DrawnRect { + draw_button_impl(ctx, ui, painter, cache, buttons, hit_regions, resource, resource, pos, fallback_size, mode, enabled, Some(action)) +} + +fn draw_button_impl( + ctx: &Context, + _ui: &mut Ui, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + mode: ButtonMode, + enabled: bool, + action: Option, +) -> DrawnRect { + let normal_texture = cache.animation(ctx, resource, VisualState::Normal); + let size = normal_texture.as_ref().map(|texture| texture.size).unwrap_or(fallback_size); + let rect = rect_from_pos_size(pos, size); + let hovered = enabled && button_contains_pointer(ctx, rect, normal_texture.as_ref()); + if hovered + && let Some(help_id) = button_help_id(resource) + { + request_help_tooltip_for_id(help_id); + } + let primary_pressed = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)); + let primary_down = ctx.input(|input| input.pointer.button_down(PointerButton::Primary)); + let primary_released = ctx.input(|input| input.pointer.button_released(PointerButton::Primary)); + + if enabled && hovered && primary_pressed { + buttons.pressed.insert(key); + } + + if !primary_down && !primary_released { + buttons.pressed.remove(key); + } + + let pressed_on_button = buttons.pressed.contains(key); + if primary_released { + if enabled && hovered && pressed_on_button { + if let Some(action) = action { + action(); + } + if matches!(mode, ButtonMode::Selected) { + buttons.selected.insert(key); + } + } + buttons.pressed.remove(key); + } + + let selected = buttons.selected.contains(key); + let visual_state = if !enabled { + VisualState::Disabled + } else if matches!(mode, ButtonMode::Momentary) && pressed_on_button && primary_down { + VisualState::Selected + } else if selected { + VisualState::Selected + } else if hovered { + VisualState::Hover + } else { + VisualState::Normal + }; + + draw_anim_state(ctx, painter, cache, hit_regions, resource, pos, size, visual_state) +} + +fn draw_explicit_button( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + selected: bool, + enabled: bool, + action: Option, + help_id: Option, +) -> DrawnRect { + let normal_texture = cache.animation(ctx, resource, VisualState::Normal); + let size = normal_texture.as_ref().map(|texture| texture.size).unwrap_or(fallback_size); + let rect = rect_from_pos_size(pos, size); + let hovered = enabled && button_contains_pointer(ctx, rect, normal_texture.as_ref()); + if hovered + && let Some(help_id) = help_id + { + request_help_tooltip_for_id(help_id); + } + + let primary_pressed = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)); + let primary_down = ctx.input(|input| input.pointer.button_down(PointerButton::Primary)); + let primary_released = ctx.input(|input| input.pointer.button_released(PointerButton::Primary)); + + if enabled && hovered && primary_pressed { + buttons.pressed.insert(key); + } + + if !primary_down && !primary_released { + buttons.pressed.remove(key); + } + + let pressed_on_button = buttons.pressed.contains(key); + if primary_released { + if enabled && hovered && pressed_on_button + && let Some(action) = action + { + action(); + } + buttons.pressed.remove(key); + } + + let visual_state = if !enabled { + VisualState::Disabled + } else if pressed_on_button && primary_down { + VisualState::Selected + } else if selected { + VisualState::Selected + } else if hovered { + VisualState::Hover + } else { + VisualState::Normal + }; + + draw_anim_state(ctx, painter, cache, hit_regions, resource, pos, size, visual_state) +} + +/// Same as `draw_explicit_button`, but always draws/hit-tests at the given `size` instead of falling +/// back to it only when the texture fails to load - `draw_explicit_button` otherwise renders at the +/// loaded texture's *native* decoded size regardless of what's requested, which the scroll arrow +/// buttons can't tolerate: they're anchored to line up exactly with the list box's edges (and, for +/// the down arrow, with the bottom of the scrollbar track), so any native-size mismatch makes them +/// visibly overshoot past that edge. +#[allow(clippy::too_many_arguments)] +fn draw_explicit_button_fixed_size( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + buttons: &mut ButtonState, + hit_regions: &mut Vec, + key: &'static str, + resource: &'static str, + pos: Pos2, + size: Vec2, + selected: bool, + enabled: bool, + action: Option, + help_id: Option, +) -> DrawnRect { + let normal_texture = cache.animation(ctx, resource, VisualState::Normal); + let rect = rect_from_pos_size(pos, size); + let hovered = enabled && button_contains_pointer(ctx, rect, normal_texture.as_ref()); + if hovered + && let Some(help_id) = help_id + { + request_help_tooltip_for_id(help_id); + } + + let primary_pressed = ctx.input(|input| input.pointer.button_pressed(PointerButton::Primary)); + let primary_down = ctx.input(|input| input.pointer.button_down(PointerButton::Primary)); + let primary_released = ctx.input(|input| input.pointer.button_released(PointerButton::Primary)); + + if enabled && hovered && primary_pressed { + buttons.pressed.insert(key); + } + + if !primary_down && !primary_released { + buttons.pressed.remove(key); + } + + let pressed_on_button = buttons.pressed.contains(key); + if primary_released { + if enabled && hovered && pressed_on_button + && let Some(action) = action + { + action(); + } + buttons.pressed.remove(key); + } + + let visual_state = if !enabled { + VisualState::Disabled + } else if pressed_on_button && primary_down { + VisualState::Selected + } else if selected { + VisualState::Selected + } else if hovered { + VisualState::Hover + } else { + VisualState::Normal + }; + + draw_anim_state_fixed_size(ctx, painter, cache, hit_regions, resource, pos, size, visual_state) +} + +fn button_help_id(resource: &str) -> Option { + match resource { + "ui/main/buyanim/buyanim" => Some(1000), + "ui/main/habitat/habitat" => Some(1002), + "ui/main/buyobj/buyobj" => Some(1001), + "ui/main/person/person" => Some(1005), + "ui/main/undo/undo" => Some(1075), + "ui/main/bdoz/bdoz" => Some(1025), + "ui/main/msgs/msgs" => Some(1006), + "ui/main/resr/resr" => Some(1019), + "ui/scenario/scenbut/scenbut" => Some(4107), + "ui/main/gameopt/gameopt" => Some(1004), + "ui/main/pause/pause" => Some(1071), + "ui/main/play/play" => Some(1072), + "ui/main/zoomin/zoomin" => Some(1007), + "ui/main/zoomout/zoomout" => Some(1023), + "ui/main/trees/trees" => Some(1066), + "ui/main/guests/guests" => Some(1068), + "ui/main/builds/builds" => Some(1067), + "ui/main/rotr/rotr" => Some(1008), + "ui/main/rotl/rotl" => Some(1009), + "ui/main/zstat/zstat" => Some(1014), + "ui/main/astat/astat" => Some(1010), + "ui/main/gstat/gstat" => Some(1012), + "ui/main/hstat/hstat" => Some(1050), + "ui/main/staff/staff" => Some(1051), + _ => None, + } +} + +fn status_meter_help_id(resource: &str) -> Option { + match resource { + "ui/main/zstat/zstat" => Some(1015), + "ui/main/astat/astat" => Some(1011), + "ui/main/gstat/gstat" => Some(1013), + _ => None, + } +} + +fn status_meter_tooltip(resource: &str) -> Option { + let help_id = status_meter_help_id(resource)?; + let value = status_meter_tooltip_value(resource)?; + let template = super::tooltip::tooltip_text_from_id(help_id, 1)?; + Some(format_numeric_help_text(&template, value)) +} + +fn status_meter_tooltip_value(resource: &str) -> Option { + let ztgamemgr = crate::globals::globals().ztgamemgr(); + match resource { + "ui/main/zstat/zstat" => Some(ztgamemgr.zoo_rating()), + "ui/main/astat/astat" => Some(ztgamemgr.animal_rating_percent()), + "ui/main/gstat/gstat" => Some(ztgamemgr.guest_rating_percent()), + _ => None, + } +} + +fn format_numeric_help_text(template: &str, value: u32) -> String { + if template.contains("%d") { + template.replace("%d", &value.to_string()) + } else { + format!("{}{}", template, value) + } +} + +fn request_help_tooltip_for_rect(ctx: &Context, rect: Rect, help_id: i32) { + if rect_contains_pointer(ctx, rect) { + request_help_tooltip_for_id(help_id); + } +} + +fn request_help_tooltip_for_id(help_id: i32) { + HELP_HOVERED_THIS_FRAME.store(true, Ordering::Release); + super::tooltip::set_overlay_help_tooltip(help_id); +} + +fn request_help_tooltip(text: String) { + HELP_HOVERED_THIS_FRAME.store(true, Ordering::Release); + super::tooltip::set_overlay_tooltip(text); +} + +fn finish_help_tooltip_frame() { + let hovered = HELP_HOVERED_THIS_FRAME.load(Ordering::Acquire); + let was_hovered = HELP_HOVERED_LAST_FRAME.swap(hovered, Ordering::AcqRel); + if was_hovered && !hovered { + super::tooltip::clear_tooltip(); + } +} + +fn rect_contains_pointer(ctx: &Context, rect: Rect) -> bool { + let Some(pos) = ctx.pointer_hover_pos().or_else(crate::ui::current_pointer_pos).or_else(crate::ui::last_pointer_pos) else { + return false; + }; + + rect.contains(pos) +} + +fn button_contains_pointer(ctx: &Context, rect: Rect, texture: Option<&LoadedTexture>) -> bool { + let Some(pos) = ctx.pointer_hover_pos().or_else(crate::ui::current_pointer_pos).or_else(crate::ui::last_pointer_pos) else { + return false; + }; + + if let Some(texture) = texture { + return HitRegion { + rect, + uv: unit_uv(), + mask: texture.mask.clone(), + } + .blocks(pos); + } + + rect.contains(pos) +} + +fn draw_anim( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, +) -> DrawnRect { + draw_anim_state(ctx, painter, cache, hit_regions, resource, pos, fallback_size, VisualState::Normal) +} + +fn draw_anim_state( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + fallback_size: Vec2, + visual_state: VisualState, +) -> DrawnRect { + let loaded = cache + .animation(ctx, resource, visual_state) + .map(|texture| (texture, visual_state)) + .or_else(|| cache.animation(ctx, resource, VisualState::Normal).map(|texture| (texture, VisualState::Normal))); + let size = loaded.as_ref().map(|(texture, _)| texture.size).unwrap_or(fallback_size); + let normal_offset = cache.animation(ctx, resource, VisualState::Normal).map(|texture| texture.offset).unwrap_or(Vec2::ZERO); + let state_offset = loaded.as_ref().map(|(texture, _)| texture.offset).unwrap_or(normal_offset); + let offset_delta = normal_offset - state_offset; + let rect = rect_from_pos_size(pos + offset_delta, size); + let loaded_image = loaded.is_some(); + if let Some((texture, loaded_visual_state)) = loaded { + let uv = unit_uv(); + let tint = if matches!(visual_state, VisualState::Disabled) && !matches!(loaded_visual_state, VisualState::Disabled) { + Color32::from_white_alpha(128) + } else { + Color32::WHITE + }; + painter.image(texture.texture.id(), rect, uv, tint); + hit_regions.push(HitRegion { + rect, + uv, + mask: texture.mask.clone(), + }); + } + + DrawnRect { rect, loaded: loaded_image } +} + +/// Like `draw_anim_state`, but always draws/hit-tests at the given `size` rather than the loaded +/// texture's native decoded size - the state-aware (hover/selected/disabled) counterpart to +/// `draw_anim_fixed_size`, which only covers the `Normal` state. +fn draw_anim_state_fixed_size( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + size: Vec2, + visual_state: VisualState, +) -> DrawnRect { + let loaded = cache + .animation(ctx, resource, visual_state) + .map(|texture| (texture, visual_state)) + .or_else(|| cache.animation(ctx, resource, VisualState::Normal).map(|texture| (texture, VisualState::Normal))); + let normal_offset = cache.animation(ctx, resource, VisualState::Normal).map(|texture| texture.offset).unwrap_or(Vec2::ZERO); + let state_offset = loaded.as_ref().map(|(texture, _)| texture.offset).unwrap_or(normal_offset); + let offset_delta = normal_offset - state_offset; + let rect = rect_from_pos_size(pos + offset_delta, size); + let loaded_image = loaded.is_some(); + if let Some((texture, loaded_visual_state)) = loaded { + let uv = unit_uv(); + let tint = if matches!(visual_state, VisualState::Disabled) && !matches!(loaded_visual_state, VisualState::Disabled) { + Color32::from_white_alpha(128) + } else { + Color32::WHITE + }; + painter.image(texture.texture.id(), rect, uv, tint); + hit_regions.push(HitRegion { + rect, + uv, + mask: texture.mask.clone(), + }); + } + + DrawnRect { rect, loaded: loaded_image } +} + +fn draw_anim_fixed_size( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + size: Vec2, +) -> DrawnRect { + let Some(texture) = cache.animation(ctx, resource, VisualState::Normal) else { + return DrawnRect { + rect: rect_from_pos_size(pos, size), + loaded: false, + }; + }; + + let rect = rect_from_pos_size(pos, size); + let uv = unit_uv(); + painter.image(texture.texture.id(), rect, uv, Color32::WHITE); + hit_regions.push(HitRegion { + rect, + uv, + mask: texture.mask.clone(), + }); + + DrawnRect { rect, loaded: true } +} + +fn draw_tiled_y( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + pos: Pos2, + height: f32, +) { + if height <= 0.0 { + return; + } + + let Some(texture) = cache.animation(ctx, resource, VisualState::Normal) else { + return; + }; + let tile_size = texture.size; + if tile_size.x <= 0.0 || tile_size.y <= 0.0 { + return; + } + + let bottom = pos.y + height; + let mut y = pos.y; + while y < bottom { + let tile_height = tile_size.y.min(bottom - y); + let dest = Rect::from_min_size(pos2(pos.x, y), vec2(tile_size.x, tile_height)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, tile_height / tile_size.y)); + painter.image(texture.texture.id(), dest, uv, Color32::WHITE); + hit_regions.push(HitRegion { + rect: dest, + uv, + mask: texture.mask.clone(), + }); + y += tile_size.y; + } +} + +fn draw_tiled_x_bottom( + ctx: &Context, + painter: &Painter, + cache: &mut TextureCache, + hit_regions: &mut Vec, + resource: &'static str, + left: f32, + bottom: f32, + width: f32, +) { + if width <= 0.0 { + return; + } + + let Some(texture) = cache.animation(ctx, resource, VisualState::Normal) else { + return; + }; + let tile_size = texture.size; + if tile_size.x <= 0.0 || tile_size.y <= 0.0 { + return; + } + + let top = bottom - tile_size.y; + let right = left + width; + let mut x = left; + while x < right { + let tile_width = tile_size.x.min(right - x); + let dest = Rect::from_min_size(pos2(x, top), vec2(tile_width, tile_size.y)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(tile_width / tile_size.x, 1.0)); + painter.image(texture.texture.id(), dest, uv, Color32::WHITE); + hit_regions.push(HitRegion { + rect: dest, + uv, + mask: texture.mask.clone(), + }); + x += tile_size.x; + } +} + +fn texture_size(ctx: &Context, cache: &mut TextureCache, resource: &'static str, visual_state: VisualState) -> Option { + cache.animation(ctx, resource, visual_state).map(|texture| texture.size) +} + +fn load_animation_texture(ctx: &Context, base: &'static str, visual_state: VisualState, missing_logged: &mut bool) -> Option { + let descriptor_name = format!("{base}.ani"); + let Some((descriptor_source, descriptor_data)) = crate::resource_manager::lazyresourcemap::get_file(&descriptor_name) else { + log_missing(missing_logged, &descriptor_name); + return None; + }; + + let descriptor = String::from_utf8_lossy(&descriptor_data).into_owned(); + let mut ini = Ini::new_cs(); + if let Err(err) = ini.read(descriptor) { + warn!("egui overlay: failed to parse animation descriptor {descriptor_name}: {err}"); + return None; + } + + let resource_name = match animation_resource_name(&ini, visual_state) { + Some(resource_name) => resource_name, + None => { + warn!("egui overlay: animation descriptor {descriptor_name} has no animation entries"); + return None; + } + }; + let Some((animation_source, animation_data)) = crate::resource_manager::lazyresourcemap::get_file(&resource_name) else { + log_missing(missing_logged, &resource_name); + return None; + }; + let Some((palette_source, palette_data)) = animation_palette_data(base, &animation_data) else { + log_missing(missing_logged, &format!("{base}.pal or embedded palette for {resource_name}")); + return None; + }; + + match zt_image::decode_animation_frames(&animation_data, &palette_data) { + Ok((animation, frames)) => { + let Some(frame) = animation.frames.first() else { + warn!("egui overlay: animation {resource_name} decoded with no frame metadata"); + return None; + }; + let Some(image) = frames.into_iter().next() else { + warn!("egui overlay: animation {resource_name} decoded with no frames"); + return None; + }; + let size = vec2(image.size[0] as f32, image.size[1] as f32); + let offset = vec2(frame_offset(frame.horizontal_offset_x), frame_offset(frame.vertical_offset_y)); + let mask = Arc::new(HitMask::from_image(&image)); + let texture = ctx.load_texture(format!("vanilla-main:{base}:{}", visual_state.animation_name()), image, egui::TextureOptions::NEAREST); + info!( + "egui overlay: loaded vanilla UI asset {base} using {descriptor_source}, {animation_source}, {palette_source} as {}x{} offset {},{}", + size.x, size.y, offset.x, offset.y + ); + Some(LoadedTexture { texture, size, offset, mask }) + } + Err(err) => { + warn!("egui overlay: failed to decode vanilla UI asset {base}: {err}"); + None + } + } +} + +fn load_tga_texture(ctx: &Context, resource: &'static str, missing_logged: &mut bool) -> Option { + let Some((source, data)) = crate::resource_manager::lazyresourcemap::get_file(resource) else { + log_missing(missing_logged, resource); + return None; + }; + + match tga::decode_tga(&data) { + Ok(image) => { + let size = vec2(image.size[0] as f32, image.size[1] as f32); + let texture = ctx.load_texture(format!("vanilla-main:{resource}"), image, egui::TextureOptions::NEAREST); + info!("egui overlay: loaded vanilla UI TGA {resource} using {source} as {}x{}", size.x, size.y); + Some(LoadedTgaTexture { texture, size }) + } + Err(err) => { + warn!("egui overlay: failed to decode vanilla UI TGA {resource}: {err}"); + None + } + } +} + +fn animation_palette_data(base: &str, animation_data: &[u8]) -> Option<(String, Box<[u8]>)> { + let embedded_palette_name = match Animation::parse(animation_data) { + Ok(animation) => animation, + Err(err) => { + warn!("egui overlay: failed to parse animation palette metadata for {base}: {err}"); + return crate::resource_manager::lazyresourcemap::get_file(&format!("{base}.pal")); + } + }; + let embedded_palette_name = normalize_resource_name(&embedded_palette_name.palette_filename); + if !embedded_palette_name.is_empty() + && let Some(palette) = crate::resource_manager::lazyresourcemap::get_file(&embedded_palette_name) + { + return Some(palette); + } + + crate::resource_manager::lazyresourcemap::get_file(&format!("{base}.pal")) +} + +fn normalize_resource_name(name: &str) -> String { + name.trim_matches(char::from(0)).replace('\\', "/").to_ascii_lowercase() +} + +fn frame_offset(value: u16) -> f32 { + (value as i16) as f32 +} + +fn animation_resource_name(ini: &Ini, visual_state: VisualState) -> Option { + let mut dirs = Vec::new(); + for index in 0.. { + let Some(dir) = ini.get("animation", &format!("dir{index}")) else { + break; + }; + dirs.push(dir); + } + + let animations = ini.get_vec("animation", "animation")?; + let animation = animations + .iter() + .find(|animation| animation.eq_ignore_ascii_case(visual_state.animation_name())) + .or_else(|| animations.iter().find(|animation| animation.eq_ignore_ascii_case("N"))) + .or_else(|| animations.first())?; + + dirs.push(animation.clone()); + Some(dirs.join("/")) +} + +fn log_missing(missing_logged: &mut bool, resource: &str) { + if !*missing_logged { + info!("egui overlay: vanilla UI resource not available yet: {resource}"); + *missing_logged = true; + } +} + +fn rect_from_pos_size(pos: Pos2, size: Vec2) -> Rect { + Rect::from_min_size(pos, vec2(size.x.max(0.0), size.y.max(0.0))) +} + +fn unit_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +fn bold_font(size: f32) -> FontId { + if BOLD_FONT_ACTIVE.load(Ordering::Acquire) { + FontId::new(size, FontFamily::Name(BOLD_FONT_FAMILY.into())) + } else { + FontId::proportional(size) + } +} + +fn prepare_bold_font(ctx: &Context) { + if BOLD_FONT_ACTIVE.load(Ordering::Acquire) { + return; + } + + if BOLD_FONT_REGISTERED.load(Ordering::Acquire) { + BOLD_FONT_ACTIVE.store(true, Ordering::Release); + return; + } + + if register_bold_font(ctx) { + BOLD_FONT_REGISTERED.store(true, Ordering::Release); + ctx.request_repaint(); + } +} + +fn register_bold_font(ctx: &Context) -> bool { + let font_bytes = match std::fs::read(BOLD_FONT_PATH) { + Ok(bytes) => bytes, + Err(err) => { + warn!("egui overlay: failed to read bold UI font {BOLD_FONT_PATH}: {err}"); + return false; + } + }; + + let mut fonts = FontDefinitions::default(); + fonts.font_data.insert(BOLD_FONT_NAME.to_string(), Arc::new(FontData::from_owned(font_bytes))); + fonts.families.insert(FontFamily::Name(BOLD_FONT_FAMILY.into()), vec![BOLD_FONT_NAME.to_string()]); + + ctx.set_fonts(fonts); + info!("egui overlay: registered bold UI font from {BOLD_FONT_PATH}"); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(width: usize, height: usize, pixels: Vec) -> ColorImage { + ColorImage::new([width, height], pixels) + } + + #[test] + fn hit_mask_blocks_opaque_pixels() { + let mask = HitMask::from_image(&image(2, 1, vec![Color32::TRANSPARENT, Color32::WHITE])); + + assert!(!mask.blocks_uv(0.25, 0.5)); + assert!(mask.blocks_uv(0.75, 0.5)); + } + + #[test] + fn hit_region_rejects_out_of_bounds_positions() { + let region = HitRegion { + rect: Rect::from_min_size(pos2(10.0, 20.0), vec2(5.0, 5.0)), + uv: unit_uv(), + mask: Arc::new(HitMask::from_image(&image(1, 1, vec![Color32::WHITE]))), + }; + + assert!(!region.blocks(pos2(9.0, 22.0))); + assert!(!region.blocks(pos2(12.0, 26.0))); + } + + #[test] + fn hit_region_maps_cropped_uv_to_source_pixels() { + let region = HitRegion { + rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 10.0)), + uv: Rect::from_min_max(pos2(0.5, 0.0), pos2(1.0, 1.0)), + mask: Arc::new(HitMask::from_image(&image( + 4, + 1, + vec![Color32::TRANSPARENT, Color32::TRANSPARENT, Color32::WHITE, Color32::TRANSPARENT], + ))), + }; + + assert!(region.blocks(pos2(1.0, 5.0))); + assert!(!region.blocks(pos2(19.0, 5.0))); + } +} diff --git a/openzt/src/ui/wndproc.rs b/openzt/src/ui/wndproc.rs new file mode 100644 index 0000000..cb3ce83 --- /dev/null +++ b/openzt/src/ui/wndproc.rs @@ -0,0 +1,204 @@ +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use egui::{Event, Modifiers, PointerButton, Pos2, Vec2}; +use openzt_detour::generated::bfwindow::ATTACH; +use openzt_detour_macro::detour_mod; +use tracing::{error, info, warn}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; +use windows::Win32::UI::WindowsAndMessaging::{ + CallWindowProcA, GWLP_WNDPROC, SetWindowLongPtrA, WM_CAPTURECHANGED, WM_LBUTTONDBLCLK, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, + WM_MOVE, WM_MOVING, WM_RBUTTONDBLCLK, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SIZE, WM_WINDOWPOSCHANGED, WNDPROC, +}; + +static ORIGINAL_WNDPROC: OnceLock>> = OnceLock::new(); +static POINTER_CAPTURED: AtomicBool = AtomicBool::new(false); + +#[detour_mod] +mod window_hooks { + use super::*; + + #[detour(ATTACH)] + unsafe extern "thiscall" fn bfwindow_attach(this: *const c_void, hwnd: i32) -> bool { + let result = unsafe { ATTACH_DETOUR.call(this, hwnd) }; + if result && hwnd != 0 { + crate::ui::capture_hwnd(HWND(hwnd as usize as *mut c_void)); + } + result + } +} + +pub fn init() { + match unsafe { window_hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized window capture detour"), + Err(err) => error!("egui overlay: failed to initialize window capture detour: {err}"), + } +} + +pub fn subclass(hwnd: HWND) { + let storage = ORIGINAL_WNDPROC.get_or_init(|| Mutex::new(None)); + let mut original = match storage.lock() { + Ok(original) => original, + Err(err) => { + error!("egui overlay: original WndProc lock poisoned: {err}"); + return; + } + }; + + if original.is_some() { + return; + } + + let previous = unsafe { SetWindowLongPtrA(hwnd, GWLP_WNDPROC, overlay_wndproc as *const () as usize as i32) }; + if previous == 0 { + warn!("egui overlay: SetWindowLongPtrA returned 0 while subclassing"); + return; + } + + *original = Some(previous as isize); + info!("egui overlay: subclassed WndProc"); +} + +unsafe extern "system" fn overlay_wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + let sync_position_after_original = matches!(msg, WM_MOVE | WM_MOVING | WM_SIZE | WM_WINDOWPOSCHANGED); + + match msg { + WM_MOUSEMOVE => { + let pos = pointer_pos(lparam); + crate::ui::push_event(Event::PointerMoved(pos)); + if should_consume_pointer_event_at(pos) { + return LRESULT(0); + } + } + WM_LBUTTONDOWN => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Primary, true); + if should_consume_pointer_event_at(pos) { + capture_pointer(hwnd); + return LRESULT(0); + } + } + WM_LBUTTONDBLCLK => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Primary, true); + if should_consume_pointer_event_at(pos) { + capture_pointer(hwnd); + return LRESULT(0); + } + } + WM_LBUTTONUP => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Primary, false); + if should_consume_pointer_event_at(pos) { + release_pointer_capture(); + return LRESULT(0); + } + release_pointer_capture(); + } + WM_RBUTTONDOWN => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Secondary, true); + if should_consume_pointer_event_at(pos) { + capture_pointer(hwnd); + return LRESULT(0); + } + } + WM_RBUTTONDBLCLK => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Secondary, true); + if should_consume_pointer_event_at(pos) { + capture_pointer(hwnd); + return LRESULT(0); + } + } + WM_RBUTTONUP => { + let pos = pointer_pos(lparam); + push_pointer_button(pos, PointerButton::Secondary, false); + if should_consume_pointer_event_at(pos) { + release_pointer_capture(); + return LRESULT(0); + } + release_pointer_capture(); + } + WM_MOUSEWHEEL => { + let delta = wheel_delta(wparam); + let pos = crate::ui::current_pointer_pos().or_else(crate::ui::last_pointer_pos); + crate::ui::push_event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::new(0.0, delta), + modifiers: Modifiers::default(), + }); + if pos.map_or_else(should_consume_pointer_event, should_consume_pointer_event_at) { + return LRESULT(0); + } + } + WM_CAPTURECHANGED => { + POINTER_CAPTURED.store(false, Ordering::Relaxed); + } + _ => {} + } + + let result = call_original(hwnd, msg, wparam, lparam); + if sync_position_after_original { + crate::ui::sync_overlay_position(hwnd); + } + result +} + +fn should_consume_pointer_event() -> bool { + POINTER_CAPTURED.load(Ordering::Relaxed) || crate::ui::blocks_pointer_input() +} + +fn should_consume_pointer_event_at(pos: Pos2) -> bool { + POINTER_CAPTURED.load(Ordering::Relaxed) || crate::ui::blocks_pointer_input_at(pos) +} + +fn capture_pointer(hwnd: HWND) { + unsafe { + SetCapture(hwnd); + } + POINTER_CAPTURED.store(true, Ordering::Relaxed); +} + +fn release_pointer_capture() { + if POINTER_CAPTURED.swap(false, Ordering::Relaxed) { + unsafe { + let _ = ReleaseCapture(); + } + } +} + +fn push_pointer_button(pos: Pos2, button: PointerButton, pressed: bool) { + crate::ui::push_event(Event::PointerButton { + pos, + button, + pressed, + modifiers: Modifiers::default(), + }); +} + +fn pointer_pos(lparam: LPARAM) -> Pos2 { + let value = lparam.0 as u32; + let x = (value & 0xffff) as u16 as i16 as f32; + let y = ((value >> 16) & 0xffff) as u16 as i16 as f32; + Pos2::new(x, y) +} + +fn wheel_delta(wparam: WPARAM) -> f32 { + let value = wparam.0 as u32; + let delta = ((value >> 16) & 0xffff) as u16 as i16; + delta as f32 +} + +fn call_original(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + let original = ORIGINAL_WNDPROC.get_or_init(|| Mutex::new(None)).lock().ok().and_then(|original| *original); + + if let Some(original) = original { + let proc: WNDPROC = unsafe { std::mem::transmute(original) }; + unsafe { CallWindowProcA(proc, hwnd, msg, wparam, lparam) } + } else { + LRESULT(0) + } +} diff --git a/openzt/src/ui/zoom_block.rs b/openzt/src/ui/zoom_block.rs new file mode 100644 index 0000000..6a443f9 --- /dev/null +++ b/openzt/src/ui/zoom_block.rs @@ -0,0 +1,33 @@ +use openzt_detour::generated::standalone::{CLICK_ZOOM_IN, CLICK_ZOOM_OUT}; +use openzt_detour_macro::detour_mod; +use tracing::{error, info}; + +#[detour_mod] +mod zoom_block_hooks { + use super::*; + + #[detour(CLICK_ZOOM_IN)] + unsafe extern "stdcall" fn click_zoom_in() { + if crate::ui::is_vanilla_ui_visible() { + return; + } + + unsafe { CLICK_ZOOM_IN_DETOUR.call() }; + } + + #[detour(CLICK_ZOOM_OUT)] + unsafe extern "stdcall" fn click_zoom_out() { + if crate::ui::is_vanilla_ui_visible() { + return; + } + + unsafe { CLICK_ZOOM_OUT_DETOUR.call() }; + } +} + +pub fn init() { + match unsafe { zoom_block_hooks::init_detours() } { + Ok(()) => info!("egui overlay: initialized vanilla zoom suppression detours"), + Err(err) => error!("egui overlay: failed to initialize vanilla zoom suppression detours: {err}"), + } +} diff --git a/openzt/src/ui/zt_image.rs b/openzt/src/ui/zt_image.rs new file mode 100644 index 0000000..45b2ad3 --- /dev/null +++ b/openzt/src/ui/zt_image.rs @@ -0,0 +1,152 @@ +use anyhow::{Context, anyhow, ensure}; +use egui::{Color32, ColorImage}; + +use crate::animation::{Animation, Frame}; + +pub fn decode_palette(data: &[u8]) -> anyhow::Result> { + ensure!(data.len() >= 4, "palette data is too short"); + + let count = u32::from_le_bytes(data[0..4].try_into().expect("slice length checked")) as usize; + let expected_len = 4usize + .checked_add(count.checked_mul(4).ok_or_else(|| anyhow!("palette size overflow"))?) + .ok_or_else(|| anyhow!("palette size overflow"))?; + ensure!(data.len() >= expected_len, "palette data is truncated"); + + let mut colors = Vec::with_capacity(count); + for chunk in data[4..expected_len].chunks_exact(4) { + colors.push(Color32::from_rgba_unmultiplied(chunk[0], chunk[1], chunk[2], chunk[3])); + } + + Ok(colors) +} + +pub fn decode_animation_frames(animation_data: &[u8], palette_data: &[u8]) -> anyhow::Result<(Animation, Vec)> { + let animation = Animation::parse(animation_data).context("failed to parse Zoo Tycoon animation")?; + let palette = decode_palette(palette_data).context("failed to parse Zoo Tycoon palette")?; + ensure!(!animation.frames.is_empty(), "animation contains no frames"); + ensure!(!palette.is_empty(), "palette contains no colors"); + + let mut frames = Vec::with_capacity(animation.frames.len()); + for frame in &animation.frames { + frames.push(frame_to_color_image(frame, &palette)?); + } + + Ok((animation, frames)) +} + +pub fn frame_to_color_image(frame: &Frame, palette: &[Color32]) -> anyhow::Result { + let width = frame.pixel_width as usize; + let height = frame.pixel_height as usize; + ensure!(width > 0 && height > 0, "animation frame dimensions must be non-zero"); + ensure!( + frame.lines.len() == height, + "animation frame has {} lines but height is {}", + frame.lines.len(), + height + ); + + let mut pixels = vec![Color32::TRANSPARENT; width * height]; + + for (y, line) in frame.lines.iter().enumerate() { + let mut x = 0usize; + + for instruction in &line.draw_instructions { + x = x + .checked_add(instruction.offset as usize) + .ok_or_else(|| anyhow!("animation draw instruction x offset overflow"))?; + + for palette_index in &instruction.colors { + ensure!(x < width, "animation draw instruction exceeds frame width at line {}", y); + + let color = palette + .get(*palette_index as usize) + .copied() + .ok_or_else(|| anyhow!("animation references missing palette index {}", palette_index))?; + pixels[y * width + x] = color; + x += 1; + } + } + } + + Ok(ColorImage::new([width, height], pixels)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::animation::{DrawInstruction, Frame, Line}; + + fn palette_bytes(count: u32, colors: &[[u8; 4]]) -> Vec { + let mut data = count.to_le_bytes().to_vec(); + for color in colors { + data.extend_from_slice(color); + } + data + } + + fn test_frame() -> Frame { + Frame { + num_bytes: 0, + pixel_height: 1, + pixel_width: 5, + vertical_offset_y: 0, + horizontal_offset_x: 0, + mystery_u16: 0, + lines: vec![Line { + num_draw_instructions: 2, + draw_instructions: vec![ + DrawInstruction { + offset: 1, + num_colors: 2, + colors: vec![1, 2], + }, + DrawInstruction { + offset: 1, + num_colors: 1, + colors: vec![3], + }, + ], + }], + } + } + + #[test] + fn decodes_rgba_palette() { + let data = palette_bytes(2, &[[10, 20, 30, 40], [50, 60, 70, 80]]); + + let palette = decode_palette(&data).unwrap(); + + assert_eq!( + palette, + vec![Color32::from_rgba_unmultiplied(10, 20, 30, 40), Color32::from_rgba_unmultiplied(50, 60, 70, 80),] + ); + } + + #[test] + fn rejects_truncated_palette() { + let data = palette_bytes(2, &[[10, 20, 30, 40]]); + + assert!(decode_palette(&data).is_err()); + } + + #[test] + fn rasterizes_offsets_and_palette_indices() { + let palette = vec![Color32::BLACK, Color32::RED, Color32::GREEN, Color32::BLUE]; + + let image = frame_to_color_image(&test_frame(), &palette).unwrap(); + + assert_eq!(image.size, [5, 1]); + assert_eq!(image.pixels[0], Color32::TRANSPARENT); + assert_eq!(image.pixels[1], Color32::RED); + assert_eq!(image.pixels[2], Color32::GREEN); + assert_eq!(image.pixels[3], Color32::TRANSPARENT); + assert_eq!(image.pixels[4], Color32::BLUE); + } + + #[test] + fn rejects_missing_palette_index() { + let palette = vec![Color32::BLACK, Color32::RED]; + + assert!(frame_to_color_image(&test_frame(), &palette).is_err()); + } +} diff --git a/openzt/src/ztgamemgr.rs b/openzt/src/ztgamemgr.rs index 2691f27..6862758 100644 --- a/openzt/src/ztgamemgr.rs +++ b/openzt/src/ztgamemgr.rs @@ -1,14 +1,18 @@ +use nt_time::{ + time::{Date, Month, Time, UtcDateTime}, +}; use tracing::info; use crate::{command_console::CommandError, globals::globals, lua_fn}; -/// ZTGameMgr struct +/// ZTGameMgr struct TODO: These a definitely u32 not u16 #[derive(Debug)] #[repr(C)] pub struct ZTGameMgr { pad1: [u8; 0x0C], cash: f32, // 0x0C - pad2: [u8; 0x30 - 0x10], // 0x0C + pad2: [u8; 0x2C - 0x10], // 0x0C + zoo_rating: u32, num_animals: u16, // 0x30 pad3: [u8; 0x38 - 0x32], // 0x30 num_species: u16, // 0x38 @@ -22,11 +26,14 @@ pub struct ZTGameMgr { num_guests_restroom_need: u16, // 0x48 pad8: [u8; 0x54 - 0x4A], // 0x48 num_guests: u16, // 0x54 - pad9: [u8; 0x1160 - 0x56], // 0x54 + pad9: [u8; 0x6c - 0x56], // 0x54 + animal_rating: u32, // 0x6c + guest_rating: u32, // 0x70 + pad10: [u8; 0x1160 - 0x74], // 0x70 zoo_admission_cost: f32, // 0x1160 - pad10: [u8; 0x1194 - 0x1164], // 0x1160 - date: Systemtime, // 0x1194 - pad11: [u8; 0x1400], // 0x1194 + pad11: [u8; 0x1194 - 0x1164], // 0x1160 + date: SystemTimeFields, // 0x1194 SYSTEMTIME + pad12: [u8; 0x1400], // 0x11a4 // TODO: Below // admissions_income_by_month: get_from_memory::<[f32; 12]>(zt_game_mgr_prt + 0x254), // concessions_benefit_by_month: get_from_memory::<[f32; 12]>(zt_game_mgr_prt + 0x29c), @@ -39,22 +46,27 @@ pub struct ZTGameMgr { // construction_cost_by_month: get_from_memory::<[f32; 12]>(zt_game_mgr_prt + 0x824), } -/// SYSTEMTIME struct from Windows API -/// TODO: Replace this with the actual SYSTEMTIME struct from the Windows API -#[derive(Debug, Clone)] -#[repr(C)] -struct Systemtime { - w_year: u16, - w_month: u16, - w_day_of_week: u16, - w_day: u16, - w_hour: u16, - w_minute: u16, - w_second: u16, - w_milliseconds: u16, -} - impl ZTGameMgr { + pub fn cash(&self) -> f32 { + self.cash + } + + pub fn zoo_rating(&self) -> u32 { + self.zoo_rating + } + + pub fn animal_rating_percent(&self) -> u32 { + ((self.animal_rating + 100) * 100) / 200 + } + + pub fn guest_rating_percent(&self) -> u32 { + ((self.guest_rating + 100) * 100) / 200 + } + + pub fn date(&self) -> Option { + self.date.to_utc_date_time() + } + /// enables or disables dev mode fn enable_dev_mode(enable: bool) { let enable_dev_mode_address = 0x63858A; @@ -64,17 +76,14 @@ impl ZTGameMgr { } } -/// a command that prints the SYSTEMTIME struct in memory in a human-readable format +/// a command that prints the date in memory in a human-readable format /// usage: `get_date` pub fn command_get_date_str(_args: Vec<&str>) -> Result { let ztgamemgr = globals().ztgamemgr(); - let date = ztgamemgr.date.clone(); + let date = ztgamemgr.date(); info!("Date: {:#?}", date); - Ok(format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", - date.w_year, date.w_month, date.w_day, date.w_hour, date.w_minute, date.w_second - )) + Ok(date.map(format_date_time).unwrap_or_else(|| "invalid SYSTEMTIME".to_string())) } /// a command that adds cash to the player's account @@ -99,7 +108,7 @@ pub fn command_enable_dev_mode(args: Vec<&str>) -> Result /// usage: `zoostats` pub fn command_zoostats(_args: Vec<&str>) -> Result { let ztgamemgr = globals().ztgamemgr(); - Ok(format!("\nBudget: {}\nAnimals: {}\nSpecies: {}\nTired Guests: {}\nHungry Guests: {}\nThirsty Guests: {}\nGuests Need Restroom: {}\nNum Guests: {}\nZoo Admission Cost: ${}", ztgamemgr.cash, ztgamemgr.num_animals, ztgamemgr.num_species, ztgamemgr.num_tired_guests, ztgamemgr.num_hungry_guests, ztgamemgr.num_thirst_guests, ztgamemgr.num_guests_restroom_need, ztgamemgr.num_guests, ztgamemgr.zoo_admission_cost)) + Ok(format!("\nBudget: {}\nAnimals: {}\nSpecies: {}\nTired Guests: {}\nHungry Guests: {}\nThirsty Guests: {}\nGuests Need Restroom: {}\nNum Guests: {}\nZoo Admission Cost: ${}\nZoo Rating: {}\nAnimal Rating: {}\nGuest Rating: {}", ztgamemgr.cash, ztgamemgr.num_animals, ztgamemgr.num_species, ztgamemgr.num_tired_guests, ztgamemgr.num_hungry_guests, ztgamemgr.num_thirst_guests, ztgamemgr.num_guests_restroom_need, ztgamemgr.num_guests, ztgamemgr.zoo_admission_cost, ztgamemgr.zoo_rating, ((ztgamemgr.animal_rating+100)*100)/200, ((ztgamemgr.guest_rating+100)*100)/200)) } /// registers the Lua functions @@ -143,3 +152,105 @@ pub fn init() { } }); } + +pub fn format_game_date(date_time: UtcDateTime) -> String { + format!( + "{}, Year {}", + month_abbreviation(date_time.month()), + game_year(date_time.year()) + ) +} + +fn format_date_time(date_time: UtcDateTime) -> String { + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + date_time.year(), + date_time.month() as u8, + date_time.day(), + date_time.hour(), + date_time.minute(), + date_time.second() + ) +} + +#[derive(Clone, Copy, Debug)] +#[repr(C)] +struct SystemTimeFields { + year: u16, + month: u16, + day_of_week: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + milliseconds: u16, +} + +impl SystemTimeFields { + fn to_utc_date_time(self) -> Option { + let month = Month::try_from(self.month as u8).ok()?; + let date = Date::from_calendar_date(self.year as i32, month, self.day as u8).ok()?; + let time = Time::from_hms_milli(self.hour as u8, self.minute as u8, self.second as u8, self.milliseconds).ok()?; + Some(UtcDateTime::new(date, time)) + } +} + +fn game_year(year: i32) -> i32 { + year - 2000 +} + +fn month_abbreviation(month: Month) -> &'static str { + match month { + Month::January => "Jan", + Month::February => "Feb", + Month::March => "Mar", + Month::April => "Apr", + Month::May => "May", + Month::June => "Jun", + Month::July => "Jul", + Month::August => "Aug", + Month::September => "Sep", + Month::October => "Oct", + Month::November => "Nov", + Month::December => "Dec", + } +} + +#[cfg(test)] +mod tests { + use nt_time::time::{Date, Month, Time, UtcDateTime}; + + use super::{format_date_time, format_game_date, SystemTimeFields}; + + fn date_time() -> UtcDateTime { + UtcDateTime::new( + Date::from_calendar_date(2001, Month::January, 1).unwrap(), + Time::from_hms(0, 0, 0).unwrap(), + ) + } + + #[test] + fn decodes_system_time() { + let system_time = SystemTimeFields { + year: 2001, + month: 1, + day_of_week: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + milliseconds: 0, + }; + assert_eq!(system_time.to_utc_date_time(), Some(date_time())); + } + + #[test] + fn formats_game_date() { + assert_eq!(format_game_date(date_time()), "Jan, Year 1"); + } + + #[test] + fn formats_console_date_time() { + assert_eq!(format_date_time(date_time()), "2001-01-01 00:00:00"); + } +} diff --git a/openzt/src/ztui.rs b/openzt/src/ztui.rs index ca0d997..c09667f 100644 --- a/openzt/src/ztui.rs +++ b/openzt/src/ztui.rs @@ -1,13 +1,17 @@ use std::fmt; use openzt_detour::generated::bfuimgr::GET_ELEMENT_0; +use openzt_detour::generated::ztmapview::{TAKE_SNAPSHOT, ZOOM_MAP}; +use openzt_detour::generated::ztui_general::GET_MAPVIEW; use openzt_detour::generated::ztui_general::GET_SELECTED_ENTITY; -use tracing::info; +use openzt_detour::generated::ztui_main::{PAUSE_GAME, UNPAUSE_GAME}; +use tracing::{info, warn}; use crate::{ command_console::CommandError, + globals::globals, lua_fn, - util::{get_from_memory, get_string_from_memory_bounded, ZTBufferString}, + util::{get_from_memory, get_string_from_memory_bounded, ZTBufferString, Addr}, ztworldmgr::read_zt_entity_from_memory, }; @@ -152,19 +156,19 @@ fn command_get_element(args: Vec<&str>) -> Result { } let address = args[0].parse()?; let get_element_fn = unsafe { GET_ELEMENT_0.original() }; - let ui_element_addr = unsafe { get_element_fn(BFUIMGR_PTR, address) }; - if ui_element_addr == 0 { + let ui_element_addr = unsafe { get_element_fn(BFUIMGR_PTR as *const u32, address) }; + if ui_element_addr.is_null() { return Err(Into::into("No element found")); } let element: UIElement = get_from_memory(ui_element_addr); - info!("{:#x} {:#x}", address, ui_element_addr); + info!("{:#x} {:#x}", address, Addr::of(ui_element_addr)); Ok(format!("{}", element)) } fn get_element(id: UIElementId) -> Option { let get_element_fn = unsafe { GET_ELEMENT_0.original() }; - let ui_element_addr = unsafe { get_element_fn(BFUIMGR_PTR, id as i32) }; - if ui_element_addr == 0 { + let ui_element_addr = unsafe { get_element_fn(BFUIMGR_PTR as *const u32, id as i32) }; + if ui_element_addr.is_null() { return None; } Some(get_from_memory(ui_element_addr)) @@ -347,19 +351,89 @@ fn command_call_ui_callback(args: Vec<&str>) -> Result { if args.len() != 1 { return Err(Into::into("Expected 1 argument")); } - let callback_function = match args[0] { - "click_continue" => unsafe { openzt_detour::generated::ztui::CLICK_CONTINUE.original() }, + match args[0] { + "click_continue" => unsafe { openzt_detour::generated::ztui::CLICK_CONTINUE.original()() }, + "click_rotate_ccw" => click_rotate_ccw(), + "click_rotate_cw" => click_rotate_cw(), + "click_zoom_in" => click_zoom_in(), + "click_zoom_out" => click_zoom_out(), + "click_snapshot" => click_snapshot(), + "click_pause" => click_pause(), + "click_unpause" => click_unpause(), "list" => { - return Ok("click_continue".to_string()); + return Ok("click_continue, click_rotate_ccw, click_rotate_cw, click_zoom_in, click_zoom_out, click_snapshot, click_pause, click_unpause".to_string()); } _ => return Err(Into::into("Unknown UI callback")), - }; - unsafe { - callback_function(); } Ok("Success".to_string()) } +pub(crate) fn click_rotate_ccw() { + unsafe { + openzt_detour::generated::standalone::CLICK_ROTATE_CCW.original()(); + } +} + +pub(crate) fn click_rotate_cw() { + unsafe { + openzt_detour::generated::standalone::CLICK_ROTATE_CW.original()(); + } +} + +pub(crate) fn click_zoom_in() { + click_zoom_by(2); +} + +pub(crate) fn click_zoom_out() { + click_zoom_by(-2); +} + +pub(crate) fn click_snapshot() { + let map_view = unsafe { GET_MAPVIEW.original()() }; + if map_view.is_null() { + warn!("egui overlay: cannot take snapshot because ZTUI::general::getMapview returned null"); + return; + } + + unsafe { + TAKE_SNAPSHOT.original()(map_view as *const i32); + } +} + +pub(crate) fn click_pause() { + unsafe { + PAUSE_GAME.original()(); + } +} + +pub(crate) fn click_unpause() { + unsafe { + UNPAUSE_GAME.original()(); + } +} + +fn click_zoom_by(delta: i32) { + let current_zoom = globals().ztworldmgr().zoom_level(); + let new_zoom = (current_zoom + delta).clamp(-2, 2); + if new_zoom == current_zoom { + return; + } + + let map_view = unsafe { GET_MAPVIEW.original()() }; + if map_view.is_null() { + warn!("egui overlay: cannot zoom map because ZTUI::general::getMapview returned null"); + return; + } + + unsafe { + ZOOM_MAP.original()(map_view, new_zoom); + } + + let world_mgr = unsafe { &mut *globals().ztworldmgr_ptr() }; + world_mgr.set_zoom_level(new_zoom); + +} + fn click_ui_element(_id: UIElementId) { // let click_element_fn = unsafe { openzt_detour::ZTUI_CLICK_ELEMENT.original() }; // unsafe {