diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d36652d..031d880 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -109,8 +109,9 @@ jobs: "override/M_IEEE.lua" "override/fpSEAM.glsl" "iee-textures/README.md" + "iee-textures/iee_effects_noise.rgba" "iee-textures/iee_water_dudv.rgba" - "iee-textures/iee_water_foam.rgba" + "iee-textures/iee_water_foam.rgba" "iee-textures/iee_water_normal.rgba" ) | Sort-Object $actual = Get-ChildItem $root -File -Recurse | ForEach-Object { diff --git a/CLAUDE.md b/CLAUDE.md index 7dcc3a9..eac5b81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,9 @@ Operational instructions for AI coding agents working in this repository. and `8x`. Other values fail closed to the existing fallback path. - `CResTileSet::h` is optional. Standard tilesets can have `header == null` while still exposing a valid 12-byte PVR entry table through `pData`. A null header does not imply missing deterministic metadata. - Deterministic detection order for this build is: - `TIS header -> PVR entry table coordinate-grid GCD -> legacy heuristic fallback` + `TIS header -> PVR entry table coordinate-grid GCD -> fail closed to 1x` + (the legacy UV/texture-id heuristic produced false 4x detections on vanilla + areas and was removed — do not reintroduce it). - `+0x1DC` is only the current linear-tiles tone flag for this build. ## Runtime Facts — Renderer / GL @@ -42,6 +44,9 @@ Operational instructions for AI coding agents working in this repository. - The engine compiles nine named GLSL programs (`fpSEAM`, `fpSprite`, `fpSELECT`, `fpCatRom`, ...). The verified slot table and shader-replacement strategy live in the graphics roadmap spec (see Docs). - The engine does **zero** lighting work. Day/night is a swap of authored assets. `CGameArea` holds authored per-area bitmaps (`m_bmLum` +0x260, `m_pbmLumNight` +0x380, `m_bmHeight` +0x388) usable as static lookup data only. - Prefer `SDL_GL_SwapWindow` (SDL2.dll export) over `DrawFlip` patterns for the frame boundary. +- The engine queues every draw (`DrawEnd_GL` only appends to `gl.cmds`) and submits the whole frame in `DrawFlush_GL` (2.7.3 RVA `0x42B350`), normally once at flip, and earlier whenever its 1024x1024 sprite atlas fills. Binding a framebuffer around an engine render function captures nothing, and an immediate GL draw of ours lands under the still-queued world. Flush first (see `features/world_post.*`), and restore every GL state touched: the flush issues changes relative to the engine's cached `gl.hwState`. +- Each queued draw command is 12 bytes (state, first vertex, count) in `gl.cmds`, counted by `gl.n`. The state word carries the blend factors (source bits 9-12, destination bits 13-16, index 1 = `GL_ONE`). The engine draws its light-emitting art (sprite render flags `0x8` / `0x200`: fires, spell effects, glows) with destination `GL_ONE`, so "additive command" is a reliable "this emits light" signal; bloom is fed from it (`game/draw_queue.*`, `detour_draw_flush`). +- `gl.pp.enabled` is forced false in 2.7.3 (`DrawInit_GL`): the engine's own offscreen target and `fpCatRom` are dead. ## Runtime Facts — Hooking Safety @@ -59,6 +64,10 @@ Operational instructions for AI coding agents working in this repository. - `src/iee/game/build_manifest.*` holds build-specific offsets, patterns, and callsites. - `src/iee/game/tis_runtime.*` holds explicit runtime views. - `src/iee/game/tile_upscale.*` holds scale selection logic. +- `src/iee/game/are_animations.*` + `src/iee/game/object_statics.*` classify + the active area's authored ARE ambient animations (fire/smoke/fountain/light + point sources) from the live CGameStatic objects; see + `docs/are-animation-detection.md`. Water bodies stay on the WED overlay path. - `docs/` contains the architecture and reverse-engineering notes future agents should read first. - `docs/threading-model.md` defines callback ownership, GL-thread rules, and ABI exception boundaries. - `docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md` is the graphics roadmap: four feature pillars, validation gates (V1-V6), and the full evaluated/dropped/rejected idea ledger. Read it before proposing any rendering feature — most ideas have already been evaluated there. diff --git a/CMakeLists.txt b/CMakeLists.txt index 75d1154..245880c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,17 +92,23 @@ add_library(iee_common STATIC src/iee/core/config.cpp src/iee/core/logger.cpp src/iee/core/pattern_scanner.cpp + src/iee/game/animation_interp.cpp + src/iee/game/are_animations.cpp src/iee/game/area_texture.cpp src/iee/game/build_manifest.cpp src/iee/game/dds_texture.cpp + src/iee/game/draw_queue.cpp src/iee/game/game_addrs.cpp + src/iee/game/object_statics.cpp src/iee/game/resref_runtime.cpp src/iee/game/shader_override.cpp + src/iee/game/sprite_motion.cpp src/iee/game/tile_liquid.cpp src/iee/game/tile_upscale.cpp src/iee/game/tis_palette.cpp src/iee/game/tis_runtime.cpp src/iee/game/wed_runtime.cpp + src/iee/game/world_post_plan.cpp ) target_include_directories(iee_common @@ -145,6 +151,7 @@ if(IEE_BUILD_WINDOWS_DLL) src/iee/dll_main.cpp src/iee/exports.cpp src/iee/features/tile_render.cpp + src/iee/features/world_post.cpp src/iee/frame_hook.cpp src/iee/hooks.cpp src/iee/shader_diagnostics.cpp @@ -189,17 +196,26 @@ if(IEE_BUILD_WINDOWS_DLL) set(IEE_RELEASE_BUNDLE_DIR "${CMAKE_BINARY_DIR}/release-bundle") set(IEE_SYMBOLS_DIR "${CMAKE_BINARY_DIR}/symbols") - add_custom_target(release_bundle + set(IEE_BUNDLE_COMMANDS COMMAND "${CMAKE_COMMAND}" -E rm -rf "${IEE_RELEASE_BUNDLE_DIR}" - COMMAND "${CMAKE_COMMAND}" -E rm -rf "${IEE_SYMBOLS_DIR}" COMMAND "${CMAKE_COMMAND}" -E make_directory "${IEE_RELEASE_BUNDLE_DIR}" - COMMAND "${CMAKE_COMMAND}" -E make_directory "${IEE_SYMBOLS_DIR}" COMMAND "${CMAKE_COMMAND}" -E copy "$" "${IEE_RELEASE_BUNDLE_DIR}/InfinityEngine-Enhancer.dll" - COMMAND "${CMAKE_COMMAND}" -E copy "$" "${IEE_SYMBOLS_DIR}/InfinityEngine-Enhancer.pdb" COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tools/InfinityEngine-Enhancer.sample.ini" "${IEE_RELEASE_BUNDLE_DIR}/InfinityEngine-Enhancer.sample.ini" COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/assets/override" "${IEE_RELEASE_BUNDLE_DIR}/override" COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tools/M_IEEE.lua" "${IEE_RELEASE_BUNDLE_DIR}/override/M_IEEE.lua" COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/assets/game-textures" "${IEE_RELEASE_BUNDLE_DIR}/iee-textures" + ) + if(MSVC) + # TARGET_PDB_FILE only resolves for linkers that emit a PDB. mingw-w64 + # cross builds have no equivalent, so keep symbol export MSVC-only. + list(APPEND IEE_BUNDLE_COMMANDS + COMMAND "${CMAKE_COMMAND}" -E rm -rf "${IEE_SYMBOLS_DIR}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${IEE_SYMBOLS_DIR}" + COMMAND "${CMAKE_COMMAND}" -E copy "$" "${IEE_SYMBOLS_DIR}/InfinityEngine-Enhancer.pdb" + ) + endif() + add_custom_target(release_bundle + ${IEE_BUNDLE_COMMANDS} DEPENDS InfinityEngine-Enhancer VERBATIM ) diff --git a/assets/game-textures/iee_effects_noise.rgba b/assets/game-textures/iee_effects_noise.rgba new file mode 100644 index 0000000..ca24548 Binary files /dev/null and b/assets/game-textures/iee_effects_noise.rgba differ diff --git a/assets/override/fpSEAM.glsl b/assets/override/fpSEAM.glsl index 7eaf726..81ec1ae 100644 --- a/assets/override/fpSEAM.glsl +++ b/assets/override/fpSEAM.glsl @@ -22,10 +22,16 @@ uniform highp vec2 uIeeZoom; // physical px per world px, per axis uniform highp vec2 uIeeViewport; // physical px (w, h) uniform highp vec2 uIeeWorldSizeInv; // 1 / world px uniform highp vec3 uIeeWaterTint; // authored water color (avg of the area's water overlay tile) +uniform highp float uIeePointCount; // 0..32 classified ambient-animation points +// Two vec4 slots per point i: [2i] = (baseX, baseY, kind, heightPx) where the +// kind fraction is a palette id (.0 warm body / .1 blue body / .2 glow only); +// [2i+1] = (halfWidthPx, reserved...). +uniform highp vec4 uIeePoints[64]; uniform lowp sampler2D uIeeAreaMask; // unit 2: one liquid mode per 64px WED cell uniform lowp sampler2D uIeeNormalMap; // unit 3: tiling water normal map uniform lowp sampler2D uIeeDudvMap; // unit 4: tiling DuDv distortion map uniform lowp sampler2D uIeeFoamMap; // unit 5: tiling foam mask +uniform lowp sampler2D uIeeNoiseMap; // unit 6: tiling FBM octaves (R/G smooth, B high-freq, A blobs) varying highp vec2 vTc; varying highp vec2 vRef; @@ -266,9 +272,184 @@ void main() vec4 base = art * vColor; gl_FragColor = vec4(base.rgb, base.a); } + + // Point-placement markers: a filled dot plus a 40px ring at every fed + // effect point — orange = fire, grey = smoke, yellow = light. Absent + // rings in ALIGN mode mean the point feed (not the effect math) is + // broken. + if (uIeePointCount > 0.5 && vColor.a > 0.9) + { + float ring = 0.0; + vec3 ringColor = vec3(0.0); + for (int i = 0; i < 32; ++i) + { + if (float(i) >= uIeePointCount) { break; } + vec4 p = uIeePoints[2 * i]; + float d = length(worldPos - p.xy); + float m = smoothstep(4.0, 1.5, abs(d - 40.0)) + smoothstep(6.0, 2.0, d); + if (m > ring) + { + ring = m; + if (p.z < 1.5) { ringColor = vec3(1.0, 0.45, 0.05); } + else if (p.z < 2.5) { ringColor = vec3(0.75, 0.75, 0.85); } + else { ringColor = vec3(1.0, 0.95, 0.20); } + } + } + if (ring > 0.02) + { + gl_FragColor = vec4(mix(gl_FragColor.rgb, ringColor, clamp(ring, 0.0, 1.0)), + gl_FragColor.a); + } + } return; } + // Ambient point effects. Base pass only; the WATER_ALPHA secondary pass + // and fades stay vanilla. Kept as flat locals (no struct/function) for + // maximum GLSL-frontend compatibility. + vec3 fxGlow = vec3(0.0); + vec3 fxFlame = vec3(0.0); + float fxFlameA = 0.0; + float fxHaze = 0.0; + if (uIeeEnabled > 0.5 && vColor.a > 0.9 && uIeePointCount > 0.5) + { + float fxT = uIeeTime; + for (int i = 0; i < 32; ++i) + { + if (float(i) >= uIeePointCount) { break; } + vec4 p = uIeePoints[2 * i]; + vec4 pb = uIeePoints[2 * i + 1]; + vec2 offs = worldPos - p.xy; + float kind = p.z; + float strength = clamp(p.w / 76.0, 0.05, 1.4); + + if (kind < 1.5) // fire: textured flame body + cast light + shimmer + { + // Authored geometry from the BAM frame table: p.w = height, + // pb.x = half-width, position already at the flame's + // bottom-center; pb.y = palette (0 warm / 1 blue / 2 glow-only). + float fh = max(p.w, 5.0); + float fw = max(pb.x, 1.5); + float palette = pb.y; + float blue = (palette > 0.5 && palette < 1.5) ? 1.0 : 0.0; + bool bodyless = palette > 1.5; + + // --- flame body (replaces the engine's BAM flame) --- + // Keep the luminous core inside the authored BAM envelope: p.xy is + // the proven bottom attachment point, so no body pixels belong below + // it. A low-opacity procedural tip may taper above the authored core; + // evaluate beyond fh so the animation never clips at a hard top edge. + if (!bodyless && offs.y <= 0.0 && offs.y > -fh * 1.3 && abs(offs.x) < fw * 2.6) + { + float v = clamp(-offs.y / fh, 0.0, 1.3); // 0 base -> 1 authored tip + float widthAt = fw * (1.05 - 0.60 * min(v, 1.0)); + float xr = offs.x / max(widthAt, 1.0); + float radial = 1.0 - clamp(xr * xr, 0.0, 1.0); + float base = 1.0 - smoothstep(-2.0, 0.0, offs.y); + float tip = 1.0 - smoothstep(0.72, 1.24, v); + float column = radial * base * tip; + // Rising noise erodes the column: calm base, ragged tip. + // Sample scale follows the flame size so small flames keep + // visible structure. + float ns = max(fh, 14.0); + float seed = p.x * 0.61 + float(i) * 19.0; + float nA = texture2D(uIeeNoiseMap, + vec2((seed + offs.x) / (ns * 0.62), + (offs.y + fxT * ns * 1.35) / (ns * 0.9))).r; + float nB = texture2D(uIeeNoiseMap, + vec2((seed * 0.37 - offs.x) / (ns * 0.36), + (offs.y + fxT * ns * 2.1) / (ns * 0.52))).b; + float intensity = column * (1.15 - v * 0.5) + - (nA * 0.78 + nB * 0.60) * (0.36 + 0.95 * v); + intensity = clamp(intensity * 1.75, 0.0, 1.0); + if (intensity > 0.02) + { + // Ramp: deep -> mid -> bright -> hot core (kept small). + vec3 c0 = mix(vec3(0.42, 0.02, 0.0), vec3(0.01, 0.06, 0.42), blue); + vec3 c1 = mix(vec3(1.0, 0.28, 0.02), vec3(0.08, 0.38, 0.95), blue); + vec3 c2 = mix(vec3(1.0, 0.70, 0.16), vec3(0.45, 0.75, 1.0), blue); + vec3 c3 = mix(vec3(1.02, 0.95, 0.72), vec3(0.85, 0.95, 1.05), blue); + vec3 flame = mix(c0, c1, smoothstep(0.02, 0.38, intensity)); + flame = mix(flame, c2, smoothstep(0.38, 0.74, intensity)); + flame = mix(flame, c3, smoothstep(0.80, 0.97, intensity)); + float a = smoothstep(0.03, 0.30, intensity); + if (a > fxFlameA) + { + fxFlameA = a; + fxFlame = flame; + } + } + } + + // --- cast light on the surroundings --- + // Elliptical footprint (isometric floor projection) with + // noise dapple so the pool reads as firelight, not a disc. + vec2 g = offs; + // Glow-only overlays (hearths) anchor at the art's floor + // edge; center their light well up onto the coals. + g.y += fh * (bodyless ? 0.85 : 0.35); + g.y *= 1.9; + float radius = 40.0 + 80.0 * strength; + float d = length(g); + if (d < radius) + { + float fall = 1.0 - d / radius; + fall *= fall; + float fi = float(i) * 7.31; + float flick = 0.80 + 0.14 * sin(fxT * 9.7 + fi) + + 0.06 * sin(fxT * 23.0 + fi * 1.7); + float dapple = 0.72 + 0.55 * texture2D(uIeeNoiseMap, + (worldPos + vec2(fxT * 5.0, -fxT * 3.0)) / 72.0).r; + vec3 glowColor = mix(vec3(1.0, 0.45, 0.15), vec3(0.30, 0.55, 1.0), + step(0.5, blue)); + fxGlow += glowColor * (fall * (0.20 + 0.38 * strength) * flick * dapple); + } + } + else if (kind < 2.5) // smoke: textured plume replacing the BAM puffs + { + float height = max(p.w, 20.0); + float rise = -offs.y; // px above the source + if (rise > -10.0 && rise < height) + { + float prog = rise / height; + float seed = p.x * 0.37 + float(i) * 11.0; + // Slow lateral wander that grows with altitude. + float sway = (texture2D(uIeeNoiseMap, + vec2(seed / 64.0 + fxT * 0.045, prog * 1.7)).a - 0.5) + * (8.0 + 34.0 * prog); + float widthPx = max(pb.x, 6.0) + 26.0 * prog; + float lateral = (offs.x - sway) / widthPx; + float across = exp(-lateral * lateral * 1.9); + // Two scrolling octaves shape the billows. + float dA = texture2D(uIeeNoiseMap, + vec2((offs.x + seed) / 96.0, (offs.y + fxT * 42.0) / 96.0)).g; + float dB = texture2D(uIeeNoiseMap, + vec2((offs.x - seed) / 48.0, (offs.y + fxT * 66.0) / 48.0)).r; + float density = clamp(dA * 0.80 + dB * 0.55 - 0.38, 0.0, 1.0); + float along = smoothstep(-10.0, 12.0, rise) * (1.0 - prog); + fxHaze += across * along * density * 0.62; + } + } + else if (kind > 3.5 && kind < 4.5) // light: steady soft glow + { + float radius = max(p.w, 10.0); + float d = length(offs); + if (d < radius) + { + float fall = 1.0 - d / radius; + fall *= fall; + float breathe = 0.92 + 0.08 * sin(fxT * 2.1 + float(i) * 3.3); + fxGlow += vec3(1.0, 0.74, 0.40) * (fall * 0.40 * breathe); + } + } + } + fxHaze = clamp(fxHaze, 0.0, 0.62); + } + + // No heat-shimmer UV distortion here: offsetting the atlas coordinate + // can cross a tile boundary and sample unrelated atlas content (a + // vertical smear-seam in the art). Shimmer belongs to a future + // world-space pass, not the tile pass. vec4 texColor = seamSample(vTc); // Inside flagged cells the engine draws the @@ -368,6 +549,34 @@ void main() texColor.a = max(texColor.a, waterMask); } + // Firelight, candle glow, and smoke haze over the (possibly water-graded) + // background art, in linear light so the warm lift does not band. + if (fxHaze > 0.004 || fxFlameA > 0.004 || fxGlow.r + fxGlow.g + fxGlow.b > 0.004) + { + vec3 lin = ieeSrgbToLinear(texColor.rgb); + float sceneLuma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); + // True black means void/unexplored: no art exists there, so cast + // light must not paint it (the additive term is luma-gated; the + // multiplicative term is naturally zero on black). + float hasArt = smoothstep(0.0015, 0.012, sceneLuma); + // Night-adaptive cast light: dark scenes get real light, bright day + // scenes only a whisper. + float castLight = (0.04 + 0.30 * (1.0 - clamp(sceneLuma * 4.0, 0.0, 1.0))) * hasArt; + lin = lin * (vec3(1.0) + fxGlow * 1.2) + fxGlow * castLight; + // Smoke veil: moonlit smoke over dark roofs, shadowy over bright + // ground. Not art-gated — a per-texel luma gate mottles dark stone, + // and smoke drifting over the night sky reads naturally. + vec3 hazeTarget = vec3(0.40, 0.40, 0.45) * (0.40 + 0.55 * sceneLuma) + vec3(0.02); + lin = mix(lin, hazeTarget, fxHaze); + // Flame body composites over everything with its own self-glow; + // flames are visible against the void (a fire at a cave mouth), so it + // is deliberately not art-gated. + lin = mix(lin, fxFlame, fxFlameA); + lin += fxFlame * fxFlameA * 0.35; + texColor.rgb = ieeLinearToSrgb(lin); + texColor.a = max(texColor.a, fxFlameA); + } + texColor = texColor * vColor; float grey = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); diff --git a/cmake/toolchains/mingw-w64.cmake b/cmake/toolchains/mingw-w64.cmake new file mode 100644 index 0000000..1b3a52d --- /dev/null +++ b/cmake/toolchains/mingw-w64.cmake @@ -0,0 +1,18 @@ +# Cross-compile the Windows DLL target from Linux with mingw-w64. +# Usage: +# cmake -S . -B build-mingw -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake \ +# -DIEE_BUILD_WINDOWS_DLL=ON -DBUILD_TESTING=OFF +# cmake --build build-mingw --target release_bundle + +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) +set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) +set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) + +set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32) +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/docs/architecture.md b/docs/architecture.md index 82c16fb..1f7ad4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,44 @@ The supported runtime is intentionally narrow: one EEex-loaded Windows DLL, one - Encapsulates scale detection. - TIS header metadata is authoritative. - Headerless PVR tables are classified from same-page coordinate deltas, not raw atlas origins. -- UV / texture-id heuristics remain fallback only. +- No heuristic tier: unresolved metadata fails closed to standard 1x + delegation (the former UV / texture-id fallback produced false 4x + detections on vanilla areas and was removed). + +`src/iee/game/are_animations.*` + +- Host-safe ARE V1.0 animation-section parsing and conservative + fire/smoke/fountain/light classification of authored ambient animations. +- The classification table grows from runtime logs of unclassified resrefs, + not speculation. See [are-animation-detection.md](are-animation-detection.md). + +`src/iee/game/sprite_motion.*` + +- Host-safe `SpriteMotionTracker`: per-sprite slide from the last shown + position to the newest logic position over one observed tick interval. +- Consumed by the sprite render hooks in `hooks.cpp` (`CGameSprite::Render`, + `RenderMarkers`, `RenderHealthBar`), which write the smoothed position into + `CGameObject::m_pos` for the duration of the engine call and restore the + logic position afterwards. Gated by `[Rendering] SmoothSpriteMovement` + (default off); the three targets resolve as a set or not at all, and only + the 2.7.3 manifest carries their patterns. + +`src/iee/game/animation_interp.*` + +- Host-safe draw-time substitution of expanded (frame-interpolated) BAMs: + validation of the expanded image, the per-cell frame chooser, and the + scoped `CVidCell` swap/restore. See item F of the 2026-10-03 roadmap for + the format and the engine facts it relies on. +- Driven from `hooks.cpp`: `CGameSprite::Render` opens the scope, the three + `CVidCell` frame accessors call `touch`. + +`src/iee/game/object_statics.*` + +- Host-safe decode of the `CGameObjectArray` globals out of the engine's + `GetShare` body (manifest pattern + RIP-operand decode) and the read-only + walk that collects the live `CGameStatic` records for an area. +- Sole source for the ARE-animation snapshot; any resolution ambiguity fails + closed and disables the scan for the session. `src/iee/hooks.*` @@ -72,6 +109,8 @@ The supported runtime is intentionally narrow: one EEex-loaded Windows DLL, one - Active-area resolution (manifest-driven CInfGame offsets), view-transform reads, and the post-LoadArea WED cache refresh plus render-thread area-texture queue. +- Also refreshes the ARE ambient-animation snapshot (`AppContext::areaAnimations`) + from the live object array at the same boundary, generation-checked. - Refresh publication is generation-checked; an older load/render callback cannot overwrite the newest immutable WED snapshot. - GL objects are recreated when the current WGL context changes. @@ -110,6 +149,9 @@ The supported runtime is intentionally narrow: one EEex-loaded Windows DLL, one - Owns atomic uniform inputs, location resolution, and render-thread uniform/texture binding. A state revision avoids repeating unchanged GL queries, sampler writes, and enhancer texture binds. +- Also carries the area's classified ambient-animation point set + (`uIeePointCount`/`uIeePoints[32]`) with its own revision, feeding the + fpSEAM fire/smoke/light point effects. `src/iee/shader_diagnostics.*` diff --git a/docs/are-animation-detection.md b/docs/are-animation-detection.md new file mode 100644 index 0000000..0c3240f --- /dev/null +++ b/docs/are-animation-detection.md @@ -0,0 +1,178 @@ +# ARE Ambient-Animation Detection + +Status: data path implemented and host-validated (in-memory CGameStatic +walk); in-game (Windows) validation pending. + +## Purpose + +The graphics roadmap (§10.3) lists emissives as blocked on classification: +light sources are BAM overlays mixed with map pixels, with no clean mask. +The ARE file's animation section **is** that classification channel — every +authored ambient animation (fireplaces, torches, chimney smoke, fountain +sprays, glows) is listed with exact world coordinates, a resref, a schedule, +and appearance flags. + +At the `LoadArea` boundary this feature collects the active area's ambient +animations by walking the engine's live `CGameStatic` objects, classifies +them, and publishes an immutable snapshot (`AppContext::areaAnimations`). + +The first consumer is the fpSEAM point-effects pass: the snapshot's shown +fire/smoke/light entries are packed into a 32-slot `uIeePoints[]` uniform +array (`build_area_effect_points`, fire prioritized under capacity pressure, +per-resref flame scale) and fed through the existing uniform bridge. The +shader draws textured **replacements**, not just halos: shaped flame bodies +eroded by rising FBM noise with a blackbody color ramp and +night-adaptive cast light; noise-billowed smoke plumes that wander and +dissipate with altitude; steady candle/glow halos. The tileable FBM octaves +ship as `iee_effects_noise.rgba` (unit 6, generated in-repo — see +`assets/game-textures/`). While the effect is active, a `CGameStatic::Render` +hook (manifest pattern, offline-verified on both builds: RVA `0x1F2570` / +`0x1F27D0`) suppresses the engine's authored fire/smoke BAM draws so the +replacements own the pixels; WBM/PVRZ setpieces and every other kind stay +vanilla, and any resolution failure just keeps the engine draws. Gated by +`[Shaders] EnablePointEffects` and the F10 master toggle. Effects that must +composite over sprites (true bloom) remain future P4 work consuming the same +points. + +Placement follows the engine's own draw rule (`CGameStatic::RenderBam`, +RVA `0x1F2B50` on 2.7.3): each frame's top-left is drawn at +`(m_pos.x - centerX, m_pos.y - m_posZ - centerY)`, with the box flipped +around `m_pos.x` when the record's mirror flag (bit 11) is set. The +replacement therefore takes its base point and size from the BAM's own frame +table: + +- `read_static_bam_envelope` walks `CVidCell::pRes` (`CResCell`: frame + table, cycle table, V1 frame lookup list) and unions the draw boxes of the + current cycle — or of every cycle when the record draws all sequences + (bit 9). The union is stable across the animation; a single frame is not. +- The point is the box's bottom-centre, its height and half-width are the + box's. Fire and smoke both use it. +- `CVidCell::m_pFrame` is **not** a usable source: `CVidCell::Render3d` + clears it at the end of every draw, so it is null whenever we look. (An + earlier revision read it and silently always fell back to the hand-tuned + table, which is why unknown flame resrefs and all smoke were misplaced.) +- The frame table exists only while the engine has the BAM loaded, and the + engine loads it on first draw. The `CGameStatic::Render` hook therefore + keeps the engine draw until `area::static_envelope_ready` has captured the + box, then suppresses. Captured boxes are cached per live object (cleared at + `LoadArea`) because the engine may release a BAM it no longer draws, and + new captures trigger a throttled republish of the point set. +- The per-resref `kFlameGeometry` table is now only the fallback for the + frames before a static's first engine draw. + +Scope boundary: **water bodies are not ARE animations.** Rivers/lakes/sea are +WED overlays and stay on the existing `wed_runtime`/`tile_liquid` path. ARE +animations cover point phenomena only (the fountain *spray*, not the pool). + +## Format facts (verified) + +Layout mirrored from NearInfinity (`org.infinity.resource.are.{AreResource,Animation}`) +and validated against real BG2EE data (AR0406, AR0700; see below). + +- ARE V1.0 header: animation **count at +0xAC**, section **offset at +0xB0**. + Only "AREA"/"V1.0" is accepted; IWD2 "V9.1" shifts offsets and fails closed. +- Animation record: fixed **76 bytes** — + name[32], X u16 @+32, Y u16 @+34 (world pixels; tile = coord/64), + schedule u32 @+36 (hour bitmask), resref[8] @+40 (BAM; EE also WBM/PVRZ), + flags u32 @+52, height i16 @+56, translucency u16 @+58. +- Flag bits (NearInfinity EE `FLAGS_ARRAY`): bit0 `Is shown`, bit1 `No shadow`, + bit2 `Not light source`, bit8 `Draw as background`, bit13 `EE: Use WBM`, + bit15 `EE: Use PVRZ`. **Animations are light sources unless bit2 is set** — + confirmed on real data: `FIRE_4` entries have bit2 clear, fly-swarm + `FLIESS` entries have bit2 set. + +## Classification + +`classify_area_animation(resref, name)` → `Fire | Smoke | Fountain | Light | None`. +Conservative prefix/substring tables; unknown entries stay `None` and are +logged per area ("ARE unclassified animation resrefs for …"). **That log line +is the mechanism that grows the table from real game data** — do not pad the +table speculatively. + +Evidence so far (full NearInfinity JSON exports, classified by the real +implementation): + +- BG1EE (596 areas, 8,517 animations): 97.7% classified — fire 7,190, + wildlife 633, smoke/fog/steam 199, light 174 (candle-named flames, glows), + water 104, fountain 16, lava 5. The remaining 196 are setpiece VFX and + static scenery (portals, dust devils, explosions, trees, drawbridges) and + intentionally stay `None`. +- BG2EE (423 areas, 3,443 animations): 84.2% classified. The remainder is + the long tail of the per-area `AM` overlay family — the + high-count members were identified by visual frame inspection of exported + BAMs and live in the built-in exact-resref table + (`kExactResrefKinds`); a curated per-resref override file for the rest is + the open proposal. +- Frame inspection also disproved a rule: `ARW[DN]*` overlays are + day/night SHADOW scenery, not lit windows. Do not classify them as light. +- Open classification questions live in the owner's + `Documents/ARE_DATA_QUESTIONS.md` triage file. + +## Primary path: in-memory CGameStatic walk + +Reverse-engineered from the PDB-named Ghidra decompilation of BGEE 2.6.6.0 +and cross-checked against the binary's disassembly: + +- The ARE animation record is `CAreaFileStaticObject` in the engine (our + `ARE_Animation_st`, byte-identical). During area load the engine reads + count/+0xAC and offset/+0xB0, strides 0x4C, and news a **`CGameStatic`** + (0x368 bytes) per record — confirming the disk parser's layout from the + engine's own loader. +- `CGameStatic` derives from the 0x60-byte `CGameObject` base + (`m_objectType` +0x8 — statics are type `0x30`/`'0'`; `m_pos` +0xC; + `m_posZ` +0x14; `m_pArea` +0x18) and **retains the raw authored record at + +0x60** + (`m_header`). Script actions (`StaticStart` show/hide, palette swaps) + mutate it in place, so the walk sees live state. +- Objects resolve through `CGameObjectArray`'s **static** globals: + `CGameObjectArray::GetShare` (RVA `0x276490` on 2.6.6.0) bounds-checks + `m_maxArrayIndex` (`0x68D434`) / `m_nextObjectId` (`0x68D438`) and indexes + a fixed entry table at `0x68D450` — `{int16 objectId; CGameObject* ptr}`, + stride 16, 15-bit index space. +- Resolution at runtime: the manifest carries EEex's version-independent + GetShare pattern (`48 C7 02 00 00 00 00 83 F9 FF`, from + `InfinityLoader.db`'s generic section); a unique match plus RIP-operand + decode of `cmp WORD PTR [rip+d], ax` and `lea r8, [rip+d]` + (`object_statics.cpp`) yields the two globals. Offline checks against the + real binaries: + - 2.6.6.0: one match at `0x276490`; operands decode to + `0x68D434`/`0x68D450`. + - 2.7.3.0 (Steam install): one match at `0x276700`, byte-identical body + shape (same operand offsets); operands decode to `0x68F8F4`/`0x68F910`. + - Both builds contain exactly one `operator new(0x368)` + + `imul ..., 0x4C` site (the CGameStatic allocation in the area loader), + confirming the object size and record stride are unchanged on 2.7.3. +- Any ambiguity (pattern count ≠ 1, duplicate/missing operands) fails closed + and disables the scan for the session. + +The walk runs fresh on every area refresh (no cache): ~`m_maxArrayIndex` +bounded reads through `safe_read`, no hooks, no writes. + +A disk reader (override → `chitin.key`/BIFF) existed as a fallback during +development and was removed by owner decision once the memory path was +offline-verified on both builds — the ARE V1.0 parser (`parse_are_animations`) +remains as the host-testable format contract for the shared record layout. +Recover the locator from branch history (`key_bif.*`) if a future build +breaks the pattern. + +## Threading and lifecycle + +- `refresh_area_animations` runs inside `refresh_wed_cache` (LoadArea thread, + or the render thread on post-transition re-resolution), after + `resolve_active_area` and independent of WED parse success. +- The walk happens outside all locks; publication takes the shared commit + mutex and re-checks the WED refresh generation, so a stale result can + never overwrite a newer area's snapshot. +- `[Detection] AreaAnimationScan` (default `true`) gates the whole path; + every failure is log-and-disable, never a gameplay block. + +## Remaining validation (Windows/in-game) + +- Confirm the memory walk in-game on 2.6.6.x: `LoadArea`-time collection + matches the authored ARE (NearInfinity or the offline probe is the + cross-check), and script show/hide toggles appear in refreshed snapshots. +- 2.7.3.x: GetShare pattern, globals, and the CGameStatic allocation site are + offline-verified against the Steam 2.7.3.0 binary (see above); the + remaining gap is the same in-game confirmation as 2.6.6.x. +- Grow the classification table from the unclassified-resref logs across a + playthrough before any consumer ships. diff --git a/docs/superpowers/plans/2026-06-11-phase2-water.md b/docs/superpowers/plans/2026-06-11-phase2-water.md index 6e55aad..850ac84 100644 --- a/docs/superpowers/plans/2026-06-11-phase2-water.md +++ b/docs/superpowers/plans/2026-06-11-phase2-water.md @@ -654,3 +654,30 @@ git push https://github.com/TheForgotten69/InfinityEngine-Enhancer.git feat/phas - The one acknowledged unknown is the world-position equation; Task 6 + runbook step 3 exist precisely to measure it, and fixes are uniform-side (no shader redesign). - Type consistency: `pack_area_liquid_texture` returns `AreaCellTexture` (Task 1) consumed in Task 2; `set_area_world_size`/`set_area_scroll_zoom` declared Task 3, called Tasks 2/4; `g_overrideEffectValue` conversion is self-contained in Task 6. ``` + +## Follow-up backlog (post-baseline observations) + +- 2026-07-18 (owner, in-game 2.7.3, v20+ baseline): some water cells/bands + render noticeably MORE TRANSPARENT than the main strongly-opaque water + body — likely the WATER_ALPHA secondary-pass suppression interacting with + cells whose only water contribution came from that pass, or alpha + differences across mask-boundary cells. Water is otherwise still correct. + Needs a dedicated alpha-consistency pass; do not fold it into unrelated + point-effect work. +- 2026-07-18 (owner, liquid-type sweep on 2.7.3): the non-water liquid modes + were exercised for the first time with these findings, all for the same + dedicated water pass: + - LAVA (BG1 AR0508/AR0514): no effect at all. Root cause identified: lava + base art is fully OPAQUE — the `waterMask = (1 - texColor.a)` alpha-hole + contour that gates the whole liquid path never engages. Lava needs a + cellMode-driven emissive treatment of opaque art instead of the + hole-mask path. + - SEWAGE (BG1 AR0224 family): acceptable in the main sewer areas; some + channels render washed-out pale grey/white — authored-tint sampling + likely failed (neutral 0.5 grey fallback) and the neutral grade reads + as paper. Verify tint-candidate coverage for sewage overlays. + - SWAMP/sea overlays (BG1 AR1200 docks etc.): harbor sea renders as flat + saturated cyan-blue "plastic" in daylight; also isolated black wedge + artifacts near authored shadow overlays. Never validated at the v20 + baseline; needs tint/grade calibration per liquid mode, not just the + water defaults. diff --git a/docs/superpowers/plans/2026-10-07-soft-fog-and-bloom.md b/docs/superpowers/plans/2026-10-07-soft-fog-and-bloom.md new file mode 100644 index 0000000..3901ca8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-soft-fog-and-bloom.md @@ -0,0 +1,1866 @@ +# Soft Fog of War and Bloom Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an INI-gated soft fog-of-war edge and a threshold bloom to the BGEE 2.7.3 world view, leaving the UI and every other build untouched. + +**Architecture:** One detour on `CInfinity::RenderFog` calls the engine's own queue flush (`DrawFlush_GL`) so the finished world is in the framebuffer, runs bloom on it, lets the engine draw its fog into our white-cleared target, flushes again, then multiplies the framebuffer by the blurred fog. Pure sizing logic is host-tested; GL work lives in one feature module behind a state guard. + +**Tech Stack:** C++20, CMake, MinHook (existing `core::Hook`), OpenGL 4.6 compatibility context through the existing `game::gl::OpenGLFunctions` table, GLSL 3.30, the repo's single-file test executable `iee_tests`. + +**Spec:** `docs/superpowers/specs/2026-10-07-soft-fog-and-bloom-design.md` + +## Global Constraints + +- BGEE 2.7.3 only. Other builds leave both features off (manifest entries empty). +- Every new address goes through `build_manifest` as pattern + reference RVA. Never an ad-hoc constant. +- All settings default off/neutral: `SoftFogOfWar=false`, `SoftFogRadius=24`, `Bloom=false`, `BloomThreshold=0.80`, `BloomStrength=0.35`. +- Any failure (unresolved pattern, missing GL entry point, incomplete framebuffer, shader error) logs once, latches the feature off, and calls the original `RenderFog` untouched. +- Never detour `CVisibilityMap::BltFogOWar3d`. +- With both settings off the detour must not flush: the frame is the engine's own. +- Restore every GL state our passes touch; the engine caches hardware state in `gl.hwState`. +- No new dependencies. No AI attribution in commits or PRs. +- Windows-only code (`windows.h`, GL table, MinHook) stays out of `iee_common` and the host tests. +- CI uses GCC 13 with `-Werror`; build with `-DIEE_WARNINGS_AS_ERRORS=ON` locally. + +## Review Focus + +1. **Window resize or resolution change mid-session.** Targets must be rebuilt at the new size; no stretched or cropped fog. Pinned by the extent-change path in Task 6 (in-game check) and `test_world_post_plan` extent cases in Task 2. +2. **Zoom at both extremes.** Fog radius must scale with zoom and never request more blur levels than the target can hold. Pinned by `blur_plan_for_radius` tests in Task 2 and the two-zoom log check in Task 7. +3. **Nothing to capture.** A fully explored area, or fog switched off in the game options, queues no fog triangles; the result must be the plain scene, not a black or white screen. Pinned by the Task 6 in-game checklist (white target multiplies by 1). +4. **Mid-frame engine flush from elsewhere.** A screenshot (`DrawReadPixels`) must still capture a correct frame with the effects on. Pinned by the Task 6 in-game checklist. +5. **Garbage INI values.** `SoftFogRadius = nan`, negative strength, threshold above 1 must clamp to safe values. Pinned by `test_config_world_post` in Task 3. + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/iee/game/world_post_plan.h/.cpp` | new | Pure sizing: extents, blur level count, radius-to-plan, zoom scale. In `iee_common`, host-tested | +| `src/iee/core/config.h/.cpp` | modify | Five new `[Rendering]` keys, clamped in `normalize` | +| `src/iee/game/build_manifest.h/.cpp` | modify | `renderFog` and `drawFlush` patterns + reference RVAs | +| `src/iee/game/game_addrs.h/.cpp` | modify | Resolve the two addresses when a world-post setting is on | +| `src/iee/game/opengl_types.h/.cpp` | modify | Framebuffer, vertex-array, buffer and fixed-function entry points; `postProcessAvailable` | +| `src/iee/core/gl_pass_guard.h` | new | RAII save/restore of the non-texture state our passes touch | +| `src/iee/features/world_post.h/.cpp` | new | GL resources and the passes; the only file that draws | +| `src/iee/hooks.cpp` | modify | `detour_render_fog`, install/uninstall, area-load notification | +| `CMakeLists.txt` | modify | New sources in `iee_common` and the DLL | +| `tests/iee_tests.cpp` | modify | Plan, config and manifest tests | +| `docs/…` , `../handoff.md` | modify | Evidence, status, runtime facts | + +Build commands used throughout (run from `InfinityEngine-Enhancer/`): + +```bash +# Host tests (CI flags) +cmake -S . -B build-host -DBUILD_TESTING=ON -DIEE_WARNINGS_AS_ERRORS=ON -DIEE_ENABLE_SANITIZERS=ON +cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure +# Windows compile check +cmake -S . -B build-mingw -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake -DIEE_BUILD_WINDOWS_DLL=ON -DIEE_WARNINGS_AS_ERRORS=ON +cmake --build build-mingw +``` + +Shell quirks on the owner's machine: `ls` is aliased (use `command ls`), zsh `noclobber` is on (use `>|`), unmatched globs are errors. + +Work on a new branch `feature/world-post` created from `feature/are-animation-detection` (the hooks this builds on live there). + +--- + +### Task 1: Depth-state question — resolved before implementation (no work) + +Spec §2.2 raised the risk that an extra `DrawFlush_GL` disturbs depth ordering. It was settled offline on 2026-10-07 and recorded in the spec; nothing here needs doing. Kept as a numbered entry so later task numbers match the spec's references. + +Finding (2.7.3, whole-binary disassembly searched for every reference): + +- `gl.v.z` (`0x142F73F7C`) is written only by `DrawInit_GL` and `DrawFlush_GL`, always to the same value, and read only by `DrawVertex_GL`. It is constant for the whole session. +- `gl.depthLockState` (`0x142F74048`) is written by the same two functions and never read. +- `DrawLockSurface_GL` (`0x42CAF0`) calls `DrawFlush_GL` whenever the engine's 1024x1024 sprite atlas fills, so flushes in the middle of the world pass already happen in vanilla on busy scenes. + +Verdict: **SAFE**. Call `DrawFlush_GL` directly; no engine globals need saving. + +--- + +### Task 2: Sizing logic (`world_post_plan`) + +**Files:** +- Create: `src/iee/game/world_post_plan.h`, `src/iee/game/world_post_plan.cpp` +- Modify: `CMakeLists.txt` (the `iee_common` source list, after `src/iee/game/wed_runtime.cpp`) +- Test: `tests/iee_tests.cpp` + +**Interfaces:** +- Produces (namespace `iee::game`): + - `struct Extent { int width{}; int height{}; }` with `operator==` + - `inline constexpr int kMaxBlurLevels = 6;` + - `struct BlurPlan { int levels{}; float offset{1.0f}; };` + - `Extent half_extent(Extent) noexcept;` + - `int available_blur_levels(Extent base) noexcept;` + - `BlurPlan blur_plan_for_radius(float radiusPixels, Extent base) noexcept;` + - `int bloom_levels(Extent base) noexcept;` + - `float pixels_per_world_pixel(float viewportWidth, float viewWorldWidth) noexcept;` + +A blur chain built on a `base` image has level `i` at `base` halved `i + 1` times. `levels == 0` means "do not blur". + +- [ ] **Step 1: Write the failing test** + +Add the include next to the other `iee/game` includes in `tests/iee_tests.cpp`: + +```cpp +#include "iee/game/world_post_plan.h" +``` + +Add before `int main()`: + +```cpp +void test_world_post_plan() { + using namespace iee::game; + + expect_true(half_extent({1920, 1080}) == Extent{960, 540}, "Half extent halves both sides"); + expect_true(half_extent({1, 1}) == Extent{1, 1}, "Half extent never reaches zero"); + expect_true(half_extent({0, 0}) == Extent{1, 1}, "A degenerate extent still yields 1x1"); + + expect_eq(available_blur_levels({1920, 1080}), 6, "A full-HD image supports the level cap"); + expect_eq(available_blur_levels({64, 64}), 3, "Levels stop before a side drops below 8"); + expect_eq(available_blur_levels({10, 10}), 0, "A tiny image supports no blur levels"); + expect_eq(available_blur_levels({0, 0}), 0, "A degenerate image supports no blur levels"); + + const auto none = blur_plan_for_radius(0.0f, {1920, 1080}); + expect_eq(none.levels, 0, "Radius 0 means no blur"); + expect_eq(blur_plan_for_radius(std::numeric_limits::quiet_NaN(), {1920, 1080}).levels, 0, + "A NaN radius means no blur"); + expect_eq(blur_plan_for_radius(-5.0f, {1920, 1080}).levels, 0, "A negative radius means no blur"); + + const auto small = blur_plan_for_radius(4.0f, {1920, 1080}); + expect_eq(small.levels, 1, "A 4 px radius needs one level"); + expect_eq(small.offset, 1.0f, "A radius equal to the level reach uses offset 1"); + + const auto typical = blur_plan_for_radius(24.0f, {1920, 1080}); + expect_eq(typical.levels, 4, "A 24 px radius needs four levels (reach 32)"); + expect_eq(typical.offset, 0.75f, "Offset scales the reach down to the radius"); + + const auto huge = blur_plan_for_radius(1000.0f, {1920, 1080}); + expect_eq(huge.levels, 6, "Levels are capped by what the image supports"); + expect_eq(huge.offset, 1.5f, "Offset is clamped so taps never skip texels badly"); + + expect_eq(blur_plan_for_radius(24.0f, {10, 10}).levels, 0, + "No blur is planned on an image too small to hold a level"); + + expect_eq(bloom_levels({960, 540}), 5, "Bloom uses at most five levels"); + expect_eq(bloom_levels({40, 40}), 2, "Bloom levels shrink with the image"); + + expect_eq(pixels_per_world_pixel(3840.0f, 1920.0f), 2.0f, "Scale is viewport over world width"); + expect_eq(pixels_per_world_pixel(3840.0f, 0.0f), 1.0f, "An unknown world width falls back to 1"); + expect_eq(pixels_per_world_pixel(0.0f, 1920.0f), 1.0f, "An unknown viewport falls back to 1"); + expect_eq(pixels_per_world_pixel(100000.0f, 1.0f), 8.0f, "Scale is clamped to a sane maximum"); + expect_eq(pixels_per_world_pixel(std::numeric_limits::infinity(), 100.0f), 1.0f, + "A non-finite input falls back to 1"); +} +``` + +Call it in `main()` after `test_animation_interpolation();`: + +```cpp + test_world_post_plan(); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build-host --target iee_tests` +Expected: compile error, `iee/game/world_post_plan.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +`src/iee/game/world_post_plan.h`: + +```cpp +#pragma once + +namespace iee::game { +// Sizing for the world post-processing passes (soft fog of war, bloom). Pure +// arithmetic so it can be tested without a GL context. + +struct Extent { + int width{}; + int height{}; + friend bool operator==(const Extent&, const Extent&) = default; +}; + +inline constexpr int kMaxBlurLevels = 6; + +// A dual-filter blur: `levels` successive half-size images below the source, +// sampled with `offset` texels of spread. levels == 0 means "do not blur". +struct BlurPlan { + int levels{}; + float offset{1.0f}; +}; + +Extent half_extent(Extent extent) noexcept; +// How many half-size levels fit below `base` before a side drops under 8 px. +int available_blur_levels(Extent base) noexcept; +// The plan whose blur reaches about `radiusPixels` on an image of `base` size. +BlurPlan blur_plan_for_radius(float radiusPixels, Extent base) noexcept; +int bloom_levels(Extent base) noexcept; +// Screen pixels per world pixel at the current zoom; 1 when either is unknown. +float pixels_per_world_pixel(float viewportWidth, float viewWorldWidth) noexcept; +} // namespace iee::game +``` + +- [ ] **Step 4: Write the implementation** + +`src/iee/game/world_post_plan.cpp`: + +```cpp +#include "iee/game/world_post_plan.h" + +#include +#include + +namespace iee::game { +namespace { +constexpr int kMinBlurExtent = 8; +constexpr int kMaxBloomLevels = 5; +} // namespace + +Extent half_extent(Extent extent) noexcept { + return {std::max(1, extent.width / 2), std::max(1, extent.height / 2)}; +} + +int available_blur_levels(Extent base) noexcept { + int levels = 0; + Extent extent = base; + while (levels < kMaxBlurLevels) { + extent = half_extent(extent); + if (std::min(extent.width, extent.height) < kMinBlurExtent) break; + ++levels; + } + return levels; +} + +BlurPlan blur_plan_for_radius(float radiusPixels, Extent base) noexcept { + // Written so NaN fails the test. + if (!(radiusPixels >= 1.0f)) return {}; + const int available = available_blur_levels(base); + if (available == 0) return {}; + int levels = 1; + while (levels < available && static_cast(1 << (levels + 1)) < radiusPixels) ++levels; + const auto reach = static_cast(1 << (levels + 1)); + return {levels, std::clamp(radiusPixels / reach, 0.5f, 1.5f)}; +} + +int bloom_levels(Extent base) noexcept { + return std::min(kMaxBloomLevels, available_blur_levels(base)); +} + +float pixels_per_world_pixel(float viewportWidth, float viewWorldWidth) noexcept { + if (!std::isfinite(viewportWidth) || !std::isfinite(viewWorldWidth)) return 1.0f; + if (!(viewportWidth > 0.0f) || !(viewWorldWidth > 0.0f)) return 1.0f; + return std::clamp(viewportWidth / viewWorldWidth, 0.25f, 8.0f); +} +} // namespace iee::game +``` + +Add to `CMakeLists.txt` in the `iee_common` list, after `src/iee/game/wed_runtime.cpp`: + +```cmake + src/iee/game/world_post_plan.cpp +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cmake -S . -B build-host -DBUILD_TESTING=ON -DIEE_WARNINGS_AS_ERRORS=ON -DIEE_ENABLE_SANITIZERS=ON && cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure` +Expected: `All InfinityEngine-Enhancer native tests passed`. + +- [ ] **Step 6: Commit** + +```bash +git add src/iee/game/world_post_plan.h src/iee/game/world_post_plan.cpp CMakeLists.txt tests/iee_tests.cpp +git commit -m "World post: sizing logic for blur chains and zoom scale" +``` + +--- + +### Task 3: Configuration keys + +**Files:** +- Modify: `src/iee/core/config.h` (struct `EngineConfig`), `src/iee/core/config.cpp` (`normalize`, `apply_kv` `[Rendering]` block, the save function's `Rendering` section) +- Test: `tests/iee_tests.cpp` + +**Interfaces:** +- Produces: `EngineConfig::softFogOfWar` (bool, false), `softFogRadius` (float, 24), `bloom` (bool, false), `bloomThreshold` (float, 0.80), `bloomStrength` (float, 0.35). After `ConfigManager::load` the floats are finite and within `[0,256]`, `[0,1]`, `[0,2]`. + +- [ ] **Step 1: Write the failing test** + +Add before `int main()` in `tests/iee_tests.cpp`: + +```cpp +void test_config_world_post() { + iee::core::EngineConfig defaults{}; + expect_true(!defaults.softFogOfWar && !defaults.bloom, "World post effects default off"); + expect_eq(defaults.softFogRadius, 24.0f, "Soft fog radius default"); + expect_eq(defaults.bloomThreshold, 0.80f, "Bloom threshold default"); + expect_eq(defaults.bloomStrength, 0.35f, "Bloom strength default"); + + const auto tempPath = + std::filesystem::current_path() / "InfinityEngine-Enhancer-world-post-test.ini"; + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogOfWar = true\n"; + out << "SoftFogRadius = 40\n"; + out << "Bloom = true\n"; + out << "BloomThreshold = 0.6\n"; + out << "BloomStrength = 0.5\n"; + } + iee::core::EngineConfig cfg{}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "World post keys should load"); + expect_true(cfg.softFogOfWar && cfg.bloom, "World post bools should parse"); + expect_eq(cfg.softFogRadius, 40.0f, "Soft fog radius should parse"); + expect_eq(cfg.bloomThreshold, 0.6f, "Bloom threshold should parse"); + expect_eq(cfg.bloomStrength, 0.5f, "Bloom strength should parse"); + + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogRadius = 100000\n"; + out << "BloomThreshold = 7\n"; + out << "BloomStrength = -3\n"; + } + cfg = {}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "Out-of-range values should load"); + expect_eq(cfg.softFogRadius, 256.0f, "Soft fog radius is clamped to its maximum"); + expect_eq(cfg.bloomThreshold, 1.0f, "Bloom threshold is clamped to 1"); + expect_eq(cfg.bloomStrength, 0.0f, "Bloom strength is clamped to 0"); + + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogRadius = nan\n"; + out << "BloomThreshold = inf\n"; + out << "BloomStrength = nan\n"; + } + cfg = {}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "Non-finite values should load"); + expect_eq(cfg.softFogRadius, 24.0f, "A non-finite radius falls back to the default"); + expect_eq(cfg.bloomThreshold, 0.80f, "A non-finite threshold falls back to the default"); + expect_eq(cfg.bloomStrength, 0.35f, "A non-finite strength falls back to the default"); + + std::error_code error; + std::filesystem::remove(tempPath, error); +} +``` + +Call it in `main()` after `test_config_detection_section();`: + +```cpp + test_config_world_post(); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build-host --target iee_tests` +Expected: compile error, `'struct iee::core::EngineConfig' has no member named 'softFogOfWar'`. + +- [ ] **Step 3: Implement** + +`src/iee/core/config.h`, after `bool interpolateAnimations = false;`: + +```cpp + bool softFogOfWar = false; + float softFogRadius = 24.0f; // world pixels; 0 = capture without blur + bool bloom = false; + float bloomThreshold = 0.80f; + float bloomStrength = 0.35f; +``` + +`src/iee/core/config.cpp`, in `normalize`, after the `lodBias` clamp: + +```cpp + if (!std::isfinite(cfg.softFogRadius)) cfg.softFogRadius = 24.0f; + if (!std::isfinite(cfg.bloomThreshold)) cfg.bloomThreshold = 0.80f; + if (!std::isfinite(cfg.bloomStrength)) cfg.bloomStrength = 0.35f; + cfg.softFogRadius = std::clamp(cfg.softFogRadius, 0.0f, 256.0f); + cfg.bloomThreshold = std::clamp(cfg.bloomThreshold, 0.0f, 1.0f); + cfg.bloomStrength = std::clamp(cfg.bloomStrength, 0.0f, 2.0f); +``` + +In `apply_kv`, in the `[Rendering]` chain, after the `InterpolateAnimations` branch and before `return;`: + +```cpp + else if (iequals(key, "SoftFogOfWar")) + assign_bool(cfg.softFogOfWar); + else if (iequals(key, "SoftFogRadius")) + assign_float(cfg.softFogRadius); + else if (iequals(key, "Bloom")) + assign_bool(cfg.bloom); + else if (iequals(key, "BloomThreshold")) + assign_float(cfg.bloomThreshold); + else if (iequals(key, "BloomStrength")) + assign_float(cfg.bloomStrength); +``` + +In the save function, after `write_bool(f, "InterpolateAnimations", cfg.interpolateAnimations);`: + +```cpp + write_bool(f, "SoftFogOfWar", cfg.softFogOfWar); + f << "SoftFogRadius = " << cfg.softFogRadius << "\n"; + write_bool(f, "Bloom", cfg.bloom); + f << "BloomThreshold = " << cfg.bloomThreshold << "\n"; + f << "BloomStrength = " << cfg.bloomStrength << "\n"; +``` + +Note: `parse_float` already rejects `nan`/`inf` text in some forms (see `test_config_numeric_bounds`, where `LODBias = nan` counts as handled by `normalize`). If the non-finite test cases fail because the parser rejects the text and keeps the default, that is the same observable result; keep the assertions as written. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure` +Expected: `All InfinityEngine-Enhancer native tests passed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/iee/core/config.h src/iee/core/config.cpp tests/iee_tests.cpp +git commit -m "Config: soft fog of war and bloom settings" +``` + +--- + +### Task 4: Manifest entries and address resolution + +Both patterns were verified offline on 2026-10-07 against the installed Steam 2.7.3 `Baldur.exe` (byte-identical to `ghidra-project/input/Baldur.exe`): each matches exactly once in `.text`, at `0x2A1B60` and `0x42B350`. + +**Files:** +- Modify: `src/iee/game/build_manifest.h` (`PatternSet`, `ReferenceRvas`), `src/iee/game/build_manifest.cpp` (2.7.3 entry and its `static_assert`s), `src/iee/game/game_addrs.h`, `src/iee/game/game_addrs.cpp` +- Test: `tests/iee_tests.cpp` + +**Interfaces:** +- Consumes: `EngineConfig::softFogOfWar`, `bloom`, `enableDebugHotkeys` (Task 3). +- Produces: `PatternSet::renderFog`, `PatternSet::drawFlush`; `ReferenceRvas::renderFog`, `ReferenceRvas::drawFlush`; `GameAddresses::RenderFog`, `GameAddresses::DrawFlush` (both 0 unless both resolved). + +- [ ] **Step 1: Write the failing test** + +Add before `int main()`: + +```cpp +void test_manifest_world_post_targets() { + const auto found = iee::game::find_manifest("BGEE 2.7.3.x"); + expect_true(found.has_value(), "The 2.7.3 manifest should be registered"); + if (!found) return; + const auto& manifest = found->get(); + expect_true(!manifest.patterns.renderFog.empty(), "2.7.3 should carry a RenderFog pattern"); + expect_true(!manifest.patterns.drawFlush.empty(), "2.7.3 should carry a DrawFlush pattern"); + expect_eq(manifest.referenceRvas.renderFog, std::uintptr_t{0x2A1B60}, "RenderFog reference RVA"); + expect_eq(manifest.referenceRvas.drawFlush, std::uintptr_t{0x42B350}, "DrawFlush reference RVA"); + expect_true(iee::game::current_manifest().patterns.renderFog.empty(), + "The 2.6.6 manifest has no world post targets"); +} +``` + +Call it in `main()` after `test_manifest_infgame_offsets();`: + +```cpp + test_manifest_world_post_targets(); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build-host --target iee_tests` +Expected: compile error, `'struct iee::game::PatternSet' has no member named 'renderFog'`. + +- [ ] **Step 3: Extend the manifest types** + +`src/iee/game/build_manifest.h`, at the end of `PatternSet`: + +```cpp + // Optional: CInfinity::RenderFog and the GL backend's queue flush + // (DrawFlush_GL). Hooked/called together for the world post passes (soft + // fog of war, bloom); either empty or non-unique leaves both off. + std::string_view renderFog{}; + std::string_view drawFlush{}; +``` + +At the end of `ReferenceRvas`: + +```cpp + std::uintptr_t renderFog{}; + std::uintptr_t drawFlush{}; +``` + +- [ ] **Step 4: Add the 2.7.3 values** + +`src/iee/game/build_manifest.cpp`, in the `"BGEE 2.7.3.x"` entry. The patterns block is positional; append after the `vidCellFrameAccessor` string (the one ending `"48 8B F2 48 85 C9 75 15 48 89 1A 33 C0",`): + +```cpp + // CInfinity::RenderFog and DrawFlush_GL (PDB-named, each + // offline-verified unique on the Steam 2.7.3.0 binary). + "48 89 5C 24 10 4C 89 44 24 18 55 56 57 41 54 41 55 41 56 41 57 48 83 EC 50 " + "48 8B D9 E8 ? ? ? ? B9 FF FF FF FF E8 ? ? ? ? B9 E1 0D 00 00", + "4C 8B DC 55 41 56 41 57 49 8D 6B D8 48 81 EC 10 01 00 00 48 8B 05 ? ? ? ? " + "48 33 C4 48 89 45 90 45 33 F6 44 39 35 ? ? ? ? 45 8B FE 0F 84", +``` + +Extend the reference RVA list of the same entry from + +```cpp + {0x27EBD0, 0x4257C0, 0x276700, 0x1F27D0, 0x36BA50, 0x36F170, 0x36E820, 0x4118A0, 0x411660, + 0x411780}, +``` + +to + +```cpp + {0x27EBD0, 0x4257C0, 0x276700, 0x1F27D0, 0x36BA50, 0x36F170, 0x36E820, 0x4118A0, 0x411660, + 0x411780, 0x2A1B60, 0x42B350}, +``` + +Add next to the other 2.7.3 `static_assert`s: + +```cpp +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.renderFog) && + validate_pattern_format(kKnownBuilds[1].patterns.drawFlush), + "2.7.3 world post pattern format is invalid"); +``` + +- [ ] **Step 5: Resolve the addresses** + +`src/iee/game/game_addrs.h`, in `GameAddresses` before `bool initialized`: + +```cpp + // Optional, all-or-nothing: the world post passes (soft fog of war, + // bloom) need the fog render to hook and the queue flush to call. + std::uintptr_t RenderFog = 0; + std::uintptr_t DrawFlush = 0; +``` + +`src/iee/game/game_addrs.cpp`: the `resolveOptional` lambda is currently defined inside the `if (cfg.smoothSpriteMovement || cfg.interpolateAnimations)` block. Move its definition to just above that `if` (unchanged body), then add after the closing brace of that block and before `const bool success = ...`: + +```cpp + // Optional targets: world post passes. Resolved only when requested + // (or when debug hotkeys can switch them on), and only as a pair. + if (cfg.softFogOfWar || cfg.bloom || cfg.enableDebugHotkeys) { + out.RenderFog = resolveOptional("CInfinity::RenderFog", manifest.patterns.renderFog, + manifest.referenceRvas.renderFog); + out.DrawFlush = resolveOptional("DrawFlush_GL", manifest.patterns.drawFlush, + manifest.referenceRvas.drawFlush); + if (out.RenderFog && out.DrawFlush) { + LOG_INFO("World post targets resolved at RVA 0x{:X} / 0x{:X}", + out.RenderFog - moduleBase, out.DrawFlush - moduleBase); + } else { + const bool known = + !manifest.patterns.renderFog.empty() && !manifest.patterns.drawFlush.empty(); + out.RenderFog = out.DrawFlush = 0; + if (cfg.softFogOfWar || cfg.bloom) { + LOG_WARN("Soft fog of war and bloom disabled: {}", + known ? "RenderFog/DrawFlush did not resolve uniquely" + : "this build has no RenderFog/DrawFlush targets"); + } + } + } +``` + +- [ ] **Step 6: Run the tests and the Windows compile check** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure` +Expected: `All InfinityEngine-Enhancer native tests passed`. + +Run: `cmake -S . -B build-mingw -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake -DIEE_BUILD_WINDOWS_DLL=ON -DIEE_WARNINGS_AS_ERRORS=ON && cmake --build build-mingw` +Expected: builds with no warnings. + +- [ ] **Step 7: Commit** + +```bash +git add src/iee/game/build_manifest.h src/iee/game/build_manifest.cpp src/iee/game/game_addrs.h src/iee/game/game_addrs.cpp tests/iee_tests.cpp +git commit -m "Manifest: CInfinity::RenderFog and DrawFlush_GL for 2.7.3" +``` + +--- + +### Task 5: GL entry points and the pass state guard + +Windows-only code; there is no host test. The deliverable is a clean mingw build and a log line reporting availability. + +**Files:** +- Modify: `src/iee/game/opengl_types.h`, `src/iee/game/opengl_types.cpp` +- Create: `src/iee/core/gl_pass_guard.h` + +**Interfaces:** +- Produces in `iee::game::gl`: the constants and `OpenGLFunctions` members listed below, and `bool OpenGLFunctions::postProcessAvailable`. +- Produces `iee::core::GlPassGuard`: default-constructible RAII type. Construct only when `postProcessAvailable` is true. Saves and restores current program, vertex array binding, array buffer binding, viewport, blend enable and functions, scissor/depth-test/cull-face enables, clear colour. It does **not** manage framebuffer bindings or texture bindings (use the existing `core::GlStateGuard({0})` for unit 0). + +- [ ] **Step 1: Add constants** + +`src/iee/game/opengl_types.h`, after `constexpr unsigned FRAMEBUFFER = 0x8D40;`: + +```cpp +constexpr unsigned READ_FRAMEBUFFER = 0x8CA8; +constexpr unsigned DRAW_FRAMEBUFFER = 0x8CA9; +constexpr unsigned DRAW_FRAMEBUFFER_BINDING = 0x8CA6; +constexpr unsigned READ_FRAMEBUFFER_BINDING = 0x8CAA; +constexpr unsigned COLOR_ATTACHMENT0 = 0x8CE0; +constexpr unsigned FRAMEBUFFER_COMPLETE = 0x8CD5; +constexpr unsigned RGBA16F = 0x881A; +constexpr unsigned HALF_FLOAT = 0x140B; +constexpr unsigned FLOAT = 0x1406; +constexpr unsigned VIEWPORT = 0x0BA2; +constexpr unsigned SCISSOR_TEST = 0x0C11; +constexpr unsigned BLEND = 0x0BE2; +constexpr unsigned DEPTH_TEST = 0x0B71; +constexpr unsigned CULL_FACE = 0x0B44; +constexpr unsigned BLEND_DST_RGB = 0x80C8; +constexpr unsigned BLEND_SRC_RGB = 0x80C9; +constexpr unsigned BLEND_DST_ALPHA = 0x80CA; +constexpr unsigned BLEND_SRC_ALPHA = 0x80CB; +constexpr unsigned CURRENT_PROGRAM = 0x8B8D; +constexpr unsigned VERTEX_ARRAY_BINDING = 0x85B5; +constexpr unsigned ARRAY_BUFFER = 0x8892; +constexpr unsigned ARRAY_BUFFER_BINDING = 0x8894; +constexpr unsigned STATIC_DRAW = 0x88E4; +constexpr unsigned COLOR_CLEAR_VALUE = 0x0C22; +constexpr unsigned COLOR_BUFFER_BIT = 0x4000; +constexpr unsigned TRIANGLES = 0x0004; +constexpr unsigned ZERO = 0; +constexpr unsigned ONE = 1; +constexpr unsigned DST_COLOR = 0x0306; +constexpr unsigned FRAGMENT_SHADER = 0x8B30; +constexpr unsigned VERTEX_SHADER = 0x8B31; +constexpr unsigned COMPILE_STATUS = 0x8B81; +constexpr unsigned LINK_STATUS = 0x8B82; +``` + +- [ ] **Step 2: Add function pointer types and members** + +In `opengl_types.h`, after the existing `using PFN_glBindFramebuffer = ...` line: + +```cpp +using PFN_glViewport = void(APIENTRY*)(int x, int y, int width, int height); +using PFN_glEnable = void(APIENTRY*)(unsigned cap); +using PFN_glDisable = void(APIENTRY*)(unsigned cap); +using PFN_glIsEnabled = unsigned char(APIENTRY*)(unsigned cap); +using PFN_glBlendFunc = void(APIENTRY*)(unsigned sfactor, unsigned dfactor); +using PFN_glClearColor = void(APIENTRY*)(float r, float g, float b, float a); +using PFN_glClear = void(APIENTRY*)(unsigned mask); +using PFN_glDrawArrays = void(APIENTRY*)(unsigned mode, int first, int count); +using PFN_glGetFloatv = void(APIENTRY*)(unsigned pname, float* data); +using PFN_glGenFramebuffers = void(APIENTRY*)(int n, unsigned* framebuffers); +using PFN_glDeleteFramebuffers = void(APIENTRY*)(int n, const unsigned* framebuffers); +using PFN_glFramebufferTexture2D = void(APIENTRY*)(unsigned target, unsigned attachment, + unsigned textarget, unsigned texture, int level); +using PFN_glCheckFramebufferStatus = unsigned(APIENTRY*)(unsigned target); +using PFN_glBlitFramebuffer = void(APIENTRY*)(int srcX0, int srcY0, int srcX1, int srcY1, + int dstX0, int dstY0, int dstX1, int dstY1, + unsigned mask, unsigned filter); +using PFN_glGenVertexArrays = void(APIENTRY*)(int n, unsigned* arrays); +using PFN_glDeleteVertexArrays = void(APIENTRY*)(int n, const unsigned* arrays); +using PFN_glBindVertexArray = void(APIENTRY*)(unsigned array); +using PFN_glGenBuffers = void(APIENTRY*)(int n, unsigned* buffers); +using PFN_glDeleteBuffers = void(APIENTRY*)(int n, const unsigned* buffers); +using PFN_glBindBuffer = void(APIENTRY*)(unsigned target, unsigned buffer); +using PFN_glBufferData = void(APIENTRY*)(unsigned target, std::ptrdiff_t size, const void* data, + unsigned usage); +using PFN_glEnableVertexAttribArray = void(APIENTRY*)(unsigned index); +using PFN_glVertexAttribPointer = void(APIENTRY*)(unsigned index, int size, unsigned type, + unsigned char normalized, int stride, + const void* pointer); +using PFN_glBlendFuncSeparate = void(APIENTRY*)(unsigned srcRgb, unsigned dstRgb, + unsigned srcAlpha, unsigned dstAlpha); +``` + +Add `#include ` at the top of the header. + +In `struct OpenGLFunctions`, after `PFN_glPixelStorei glPixelStorei{};`: + +```cpp + PFN_glViewport glViewport{}; + PFN_glEnable glEnable{}; + PFN_glDisable glDisable{}; + PFN_glIsEnabled glIsEnabled{}; + PFN_glBlendFunc glBlendFunc{}; + PFN_glClearColor glClearColor{}; + PFN_glClear glClear{}; + PFN_glDrawArrays glDrawArrays{}; + PFN_glGetFloatv glGetFloatv{}; +``` + +After `PFN_glBindFramebuffer glBindFramebuffer{};`: + +```cpp + PFN_glGenFramebuffers glGenFramebuffers{}; + PFN_glDeleteFramebuffers glDeleteFramebuffers{}; + PFN_glFramebufferTexture2D glFramebufferTexture2D{}; + PFN_glCheckFramebufferStatus glCheckFramebufferStatus{}; + PFN_glBlitFramebuffer glBlitFramebuffer{}; + PFN_glGenVertexArrays glGenVertexArrays{}; + PFN_glDeleteVertexArrays glDeleteVertexArrays{}; + PFN_glBindVertexArray glBindVertexArray{}; + PFN_glGenBuffers glGenBuffers{}; + PFN_glDeleteBuffers glDeleteBuffers{}; + PFN_glBindBuffer glBindBuffer{}; + PFN_glBufferData glBufferData{}; + PFN_glEnableVertexAttribArray glEnableVertexAttribArray{}; + PFN_glVertexAttribPointer glVertexAttribPointer{}; + PFN_glBlendFuncSeparate glBlendFuncSeparate{}; +``` + +After `bool compressedTextureUploadAvailable{false};`: + +```cpp + // Everything the world post passes need: framebuffer objects, a vertex + // array, shaders with introspection, and the fixed-function state calls. + bool postProcessAvailable{false}; +``` + +- [ ] **Step 3: Load them** + +`src/iee/game/opengl_types.cpp`, in `OpenGLFunctions::initialize`, after the `glPixelStorei` load: + +```cpp + glViewport = reinterpret_cast(get_gl1_proc_address(opengl32, "glViewport")); + glEnable = reinterpret_cast(get_gl1_proc_address(opengl32, "glEnable")); + glDisable = reinterpret_cast(get_gl1_proc_address(opengl32, "glDisable")); + glIsEnabled = reinterpret_cast(get_gl1_proc_address(opengl32, "glIsEnabled")); + glBlendFunc = reinterpret_cast(get_gl1_proc_address(opengl32, "glBlendFunc")); + glClearColor = + reinterpret_cast(get_gl1_proc_address(opengl32, "glClearColor")); + glClear = reinterpret_cast(get_gl1_proc_address(opengl32, "glClear")); + glDrawArrays = + reinterpret_cast(get_gl1_proc_address(opengl32, "glDrawArrays")); + glGetFloatv = reinterpret_cast(get_gl1_proc_address(opengl32, "glGetFloatv")); +``` + +After the `glBindFramebuffer` load: + +```cpp + glGenFramebuffers = reinterpret_cast( + get_ext_proc_address(opengl32, "glGenFramebuffers")); + glDeleteFramebuffers = reinterpret_cast( + get_ext_proc_address(opengl32, "glDeleteFramebuffers")); + glFramebufferTexture2D = reinterpret_cast( + get_ext_proc_address(opengl32, "glFramebufferTexture2D")); + glCheckFramebufferStatus = reinterpret_cast( + get_ext_proc_address(opengl32, "glCheckFramebufferStatus")); + glBlitFramebuffer = reinterpret_cast( + get_ext_proc_address(opengl32, "glBlitFramebuffer")); + glGenVertexArrays = reinterpret_cast( + get_ext_proc_address(opengl32, "glGenVertexArrays")); + glDeleteVertexArrays = reinterpret_cast( + get_ext_proc_address(opengl32, "glDeleteVertexArrays")); + glBindVertexArray = reinterpret_cast( + get_ext_proc_address(opengl32, "glBindVertexArray")); + glGenBuffers = reinterpret_cast(get_ext_proc_address(opengl32, "glGenBuffers")); + glDeleteBuffers = + reinterpret_cast(get_ext_proc_address(opengl32, "glDeleteBuffers")); + glBindBuffer = reinterpret_cast(get_ext_proc_address(opengl32, "glBindBuffer")); + glBufferData = reinterpret_cast(get_ext_proc_address(opengl32, "glBufferData")); + glEnableVertexAttribArray = reinterpret_cast( + get_ext_proc_address(opengl32, "glEnableVertexAttribArray")); + glVertexAttribPointer = reinterpret_cast( + get_ext_proc_address(opengl32, "glVertexAttribPointer")); + glBlendFuncSeparate = reinterpret_cast( + get_ext_proc_address(opengl32, "glBlendFuncSeparate")); +``` + +After the `compressedTextureUploadAvailable = ...` line: + +```cpp + postProcessAvailable = + textureUploadAvailable && readyForSourcePatching && glViewport && glEnable && glDisable && + glIsEnabled && glBlendFunc && glClearColor && glClear && glDrawArrays && glGetFloatv && + glBindFramebuffer && glGenFramebuffers && glDeleteFramebuffers && glFramebufferTexture2D && + glCheckFramebufferStatus && glBlitFramebuffer && glGenVertexArrays && + glDeleteVertexArrays && glBindVertexArray && glGenBuffers && glDeleteBuffers && + glBindBuffer && glBufferData && glEnableVertexAttribArray && glVertexAttribPointer && + glBlendFuncSeparate; +``` + +Extend the existing `LOG_INFO("OpenGL initialized (...` call: add `, post process={}` to the format string and `postProcessAvailable ? "ready" : "partial"` as the last argument. + +- [ ] **Step 4: Write the guard** + +`src/iee/core/gl_pass_guard.h`: + +```cpp +#pragma once + +#include "iee/game/opengl_types.h" + +namespace iee::core { +// Saves the GL state our own draw passes change and puts it back on scope +// exit. The engine issues state changes relative to what it believes the +// hardware state is, so anything left altered corrupts its next draws. +// Framebuffer and texture bindings are not covered: the caller owns the +// framebuffer hand-over, and GlStateGuard covers texture units. +// Requires OpenGLFunctions::postProcessAvailable. +class GlPassGuard { + public: + GlPassGuard() noexcept { + const auto& gl = game::gl::get_gl_functions(); + gl.glGetIntegerv(game::gl::CURRENT_PROGRAM, &program_); + gl.glGetIntegerv(game::gl::VERTEX_ARRAY_BINDING, &vertexArray_); + gl.glGetIntegerv(game::gl::ARRAY_BUFFER_BINDING, &arrayBuffer_); + gl.glGetIntegerv(game::gl::VIEWPORT, viewport_); + gl.glGetIntegerv(game::gl::BLEND_SRC_RGB, &blendSrcRgb_); + gl.glGetIntegerv(game::gl::BLEND_DST_RGB, &blendDstRgb_); + gl.glGetIntegerv(game::gl::BLEND_SRC_ALPHA, &blendSrcAlpha_); + gl.glGetIntegerv(game::gl::BLEND_DST_ALPHA, &blendDstAlpha_); + gl.glGetFloatv(game::gl::COLOR_CLEAR_VALUE, clearColor_); + blend_ = gl.glIsEnabled(game::gl::BLEND) != 0; + scissor_ = gl.glIsEnabled(game::gl::SCISSOR_TEST) != 0; + depthTest_ = gl.glIsEnabled(game::gl::DEPTH_TEST) != 0; + cullFace_ = gl.glIsEnabled(game::gl::CULL_FACE) != 0; + } + + ~GlPassGuard() noexcept { + const auto& gl = game::gl::get_gl_functions(); + const auto set = [&](unsigned capability, bool enabled) { + if (enabled) { + gl.glEnable(capability); + } else { + gl.glDisable(capability); + } + }; + set(game::gl::BLEND, blend_); + set(game::gl::SCISSOR_TEST, scissor_); + set(game::gl::DEPTH_TEST, depthTest_); + set(game::gl::CULL_FACE, cullFace_); + gl.glBlendFuncSeparate( + static_cast(blendSrcRgb_), static_cast(blendDstRgb_), + static_cast(blendSrcAlpha_), static_cast(blendDstAlpha_)); + gl.glClearColor(clearColor_[0], clearColor_[1], clearColor_[2], clearColor_[3]); + gl.glViewport(viewport_[0], viewport_[1], viewport_[2], viewport_[3]); + gl.glBindVertexArray(static_cast(vertexArray_)); + gl.glBindBuffer(game::gl::ARRAY_BUFFER, static_cast(arrayBuffer_)); + gl.glUseProgram(static_cast(program_)); + } + + GlPassGuard(const GlPassGuard&) = delete; + GlPassGuard& operator=(const GlPassGuard&) = delete; + + // The viewport as the engine left it: {x, y, width, height}. + [[nodiscard]] const int* viewport() const noexcept { return viewport_; } + + private: + int program_{}; + int vertexArray_{}; + int arrayBuffer_{}; + int viewport_[4]{}; + int blendSrcRgb_{}; + int blendDstRgb_{}; + int blendSrcAlpha_{}; + int blendDstAlpha_{}; + float clearColor_[4]{}; + bool blend_{}; + bool scissor_{}; + bool depthTest_{}; + bool cullFace_{}; +}; +} // namespace iee::core +``` + +- [ ] **Step 5: Compile check** + +The guard header is not included anywhere yet; add a temporary `#include "iee/core/gl_pass_guard.h"` at the top of `src/iee/game/opengl_types.cpp`, build, then remove it. + +Run: `cmake --build build-mingw` +Expected: builds with no warnings. Remove the temporary include and rebuild. + +- [ ] **Step 6: Commit** + +```bash +git add src/iee/game/opengl_types.h src/iee/game/opengl_types.cpp src/iee/core/gl_pass_guard.h +git commit -m "GL: framebuffer and vertex-array entry points, pass state guard" +``` + +--- + +### Task 6: Fog capture without blur, wired to the hook (first in-game bundle) + +This is spec §8 step 1: the engine's fog goes through our target and comes back unblurred. The picture must be indistinguishable from vanilla. Blur and bloom arrive in Tasks 7 and 8 and reuse everything built here. + +**Files:** +- Create: `src/iee/features/world_post.h`, `src/iee/features/world_post.cpp` +- Modify: `src/iee/hooks.cpp`, `CMakeLists.txt` (DLL source list, after `src/iee/features/tile_render.cpp`) + +**Interfaces:** +- Consumes: `game::Extent`, `game::pixels_per_world_pixel` (Task 2); `EngineConfig` fields (Task 3); `GameAddresses::RenderFog`, `DrawFlush` (Task 4); `gl::OpenGLFunctions::postProcessAvailable`, `core::GlPassGuard` (Task 5); existing `core::GlStateGuard`, `area::read_view_transform`, `area::ViewTransform`. +- Produces (namespace `iee::features`, all `noexcept`, render thread only unless noted): + - `void world_post_configure(const core::EngineConfig& cfg) noexcept;` + - `bool world_post_active() noexcept;` — true when an effect is on and nothing has failed + - `bool world_post_before_fog(float viewWorldWidth) noexcept;` — call right after flushing; returns true when the fog target is now bound and the caller must flush after the original `RenderFog` and then call `world_post_after_fog()` + - `void world_post_after_fog() noexcept;` + - `void world_post_on_area_load() noexcept;` — any thread; re-arms the once-per-area log + - `void world_post_forget() noexcept;` — drops handles without GL calls (shutdown) + +- [ ] **Step 1: Write the header** + +`src/iee/features/world_post.h`: + +```cpp +#pragma once + +namespace iee::core { +struct EngineConfig; +} + +namespace iee::features { +// World post passes: soft fog of war and bloom, run from the +// CInfinity::RenderFog detour. The engine queues the whole frame and only +// draws in DrawFlush_GL, so the caller flushes that queue around these calls: +// +// flush(); // world is now in the framebuffer +// bool capturing = world_post_before_fog(); // bloom; fog target bound if true +// original RenderFog // queues the fog triangles +// if (capturing) { flush(); world_post_after_fog(); } +// +// Render thread only, except world_post_on_area_load. + +void world_post_configure(const core::EngineConfig& cfg) noexcept; +// True when at least one effect is switched on and no pass has failed. +bool world_post_active() noexcept; +// viewWorldWidth: world pixels visible across the view (0 when unknown). +bool world_post_before_fog(float viewWorldWidth) noexcept; +void world_post_after_fog() noexcept; +// Any thread. The next frame logs what engaged. +void world_post_on_area_load() noexcept; +// Drops every GL handle without touching GL. For shutdown paths. +void world_post_forget() noexcept; +} // namespace iee::features +``` + +- [ ] **Step 2: Write the implementation** + +`src/iee/features/world_post.cpp`: + +```cpp +#include "iee/features/world_post.h" + +#include + +#include + +#include "iee/core/config.h" +#include "iee/core/gl_pass_guard.h" +#include "iee/core/gl_state_guard.h" +#include "iee/core/logger.h" +#include "iee/game/opengl_types.h" +#include "iee/game/world_post_plan.h" + +namespace iee::features { +namespace { +namespace gl = game::gl; + +struct Target { + unsigned texture{}; + unsigned framebuffer{}; + game::Extent extent{}; +}; + +struct CompositeProgram { + unsigned id{}; + int scale{-1}; + int dither{-1}; +}; + +struct State { + bool softFog{}; + bool bloom{}; + float fogRadius{}; + float bloomThreshold{}; + float bloomStrength{}; + + bool failed{}; + HGLRC context{}; + game::Extent viewport{}; + int engineDrawFramebuffer{}; + int engineReadFramebuffer{}; + float pixelsPerWorldPixel{1.0f}; + + unsigned vertexArray{}; + unsigned vertexBuffer{}; + CompositeProgram composite{}; + Target fogCapture{}; + bool fogDrawnOnce{}; +}; + +State g_state; +std::atomic g_logNextFrame{true}; + +constexpr const char* kVertexSource = R"glsl(#version 330 +layout(location = 0) in vec2 aPosition; +out vec2 vUv; +void main() { + vUv = aPosition * 0.5 + 0.5; + gl_Position = vec4(aPosition, 0.0, 1.0); +} +)glsl"; + +// Writes the texture scaled by uScale; the caller's blend mode decides whether +// that multiplies (fog) or adds (bloom). uDither adds +-0.5/255 of ordered +// noise to hide banding in long dark gradients. +constexpr const char* kCompositeSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform float uScale; +uniform float uDither; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec3 color = texture(uTexture, vUv).rgb * uScale; + float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + fragColor = vec4(color + uDither * (noise - 0.5) / 255.0, 1.0); +} +)glsl"; + +void fail(const char* reason) noexcept { + g_state.failed = true; + try { + LOG_WARN("Soft fog of war and bloom disabled for this session: {}", reason); + } catch (...) { + } +} + +unsigned compile_shader(unsigned type, const char* source) noexcept { + const auto& fn = gl::get_gl_functions(); + const unsigned shader = fn.glCreateShader(type); + if (!shader) return 0; + fn.glShaderSource(shader, 1, &source, nullptr); + fn.glCompileShader(shader); + int status = 0; + fn.glGetShaderiv(shader, gl::COMPILE_STATUS, &status); + if (!status) { + char log[512]{}; + fn.glGetShaderInfoLog(shader, static_cast(sizeof(log)) - 1, nullptr, log); + try { + LOG_WARN("World post shader compile failed: {}", log); + } catch (...) { + } + fn.glDeleteShader(shader); + return 0; + } + return shader; +} + +unsigned build_program(const char* fragmentSource) noexcept { + const auto& fn = gl::get_gl_functions(); + const unsigned vertex = compile_shader(gl::VERTEX_SHADER, kVertexSource); + const unsigned fragment = compile_shader(gl::FRAGMENT_SHADER, fragmentSource); + unsigned program = 0; + if (vertex && fragment) { + program = fn.glCreateProgram(); + fn.glAttachShader(program, vertex); + fn.glAttachShader(program, fragment); + fn.glLinkProgram(program); + int status = 0; + fn.glGetProgramiv(program, gl::LINK_STATUS, &status); + if (!status) { + char log[512]{}; + fn.glGetProgramInfoLog(program, static_cast(sizeof(log)) - 1, nullptr, log); + try { + LOG_WARN("World post program link failed: {}", log); + } catch (...) { + } + fn.glDeleteProgram(program); + program = 0; + } + } + if (vertex) fn.glDeleteShader(vertex); + if (fragment) fn.glDeleteShader(fragment); + return program; +} + +void destroy_target(Target& target) noexcept { + const auto& fn = gl::get_gl_functions(); + if (target.framebuffer) fn.glDeleteFramebuffers(1, &target.framebuffer); + if (target.texture) fn.glDeleteTextures(1, &target.texture); + target = {}; +} + +// Leaves the new framebuffer bound; the caller rebinds what it needs. +bool create_target(Target& target, game::Extent extent, unsigned internalFormat, + unsigned type) noexcept { + const auto& fn = gl::get_gl_functions(); + destroy_target(target); + fn.glGenTextures(1, &target.texture); + fn.glBindTexture(gl::TEXTURE_2D, target.texture); + fn.glTexImage2D(gl::TEXTURE_2D, 0, static_cast(internalFormat), extent.width, + extent.height, 0, gl::RGBA, type, nullptr); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, static_cast(gl::LINEAR)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, static_cast(gl::LINEAR)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, static_cast(gl::CLAMP_TO_EDGE)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, static_cast(gl::CLAMP_TO_EDGE)); + fn.glGenFramebuffers(1, &target.framebuffer); + fn.glBindFramebuffer(gl::FRAMEBUFFER, target.framebuffer); + fn.glFramebufferTexture2D(gl::FRAMEBUFFER, gl::COLOR_ATTACHMENT0, gl::TEXTURE_2D, + target.texture, 0); + if (fn.glCheckFramebufferStatus(gl::FRAMEBUFFER) != gl::FRAMEBUFFER_COMPLETE) { + destroy_target(target); + return false; + } + target.extent = extent; + return true; +} + +void bind_engine_framebuffer() noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, + static_cast(g_state.engineDrawFramebuffer)); + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, + static_cast(g_state.engineReadFramebuffer)); +} + +// State every pass of ours draws with. The guard in scope restores it. +void set_pass_state() noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glDisable(gl::SCISSOR_TEST); + fn.glDisable(gl::DEPTH_TEST); + fn.glDisable(gl::CULL_FACE); + fn.glDisable(gl::BLEND); + fn.glBindVertexArray(g_state.vertexArray); + fn.glActiveTexture(gl::TEXTURE0); +} + +void draw_composite(unsigned texture, float scale, float dither) noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glUseProgram(g_state.composite.id); + fn.glUniform1f(g_state.composite.scale, scale); + fn.glUniform1f(g_state.composite.dither, dither); + fn.glBindTexture(gl::TEXTURE_2D, texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); +} + +bool create_shared_resources() noexcept { + const auto& fn = gl::get_gl_functions(); + static constexpr float kTriangle[] = {-1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f}; + fn.glGenVertexArrays(1, &g_state.vertexArray); + fn.glGenBuffers(1, &g_state.vertexBuffer); + fn.glBindVertexArray(g_state.vertexArray); + fn.glBindBuffer(gl::ARRAY_BUFFER, g_state.vertexBuffer); + fn.glBufferData(gl::ARRAY_BUFFER, sizeof(kTriangle), kTriangle, gl::STATIC_DRAW); + fn.glEnableVertexAttribArray(0); + fn.glVertexAttribPointer(0, 2, gl::FLOAT, 0, 0, nullptr); + + g_state.composite.id = build_program(kCompositeSource); + if (!g_state.composite.id) return false; + g_state.composite.scale = fn.glGetUniformLocation(g_state.composite.id, "uScale"); + g_state.composite.dither = fn.glGetUniformLocation(g_state.composite.id, "uDither"); + fn.glUseProgram(g_state.composite.id); + fn.glUniform1i(fn.glGetUniformLocation(g_state.composite.id, "uTexture"), 0); + return true; +} + +bool create_sized_resources(game::Extent viewport) noexcept { + if (!create_target(g_state.fogCapture, viewport, gl::RGBA8, gl::UNSIGNED_BYTE)) return false; + g_state.viewport = viewport; + return true; +} + +// Called inside a GlPassGuard + GlStateGuard scope: creation changes bindings. +bool ensure_resources(game::Extent viewport) noexcept { + const auto context = gl::current_context(); + if (context != g_state.context) { + // The old context is gone and took its objects with it. + const State kept = g_state; + g_state = {}; + g_state.softFog = kept.softFog; + g_state.bloom = kept.bloom; + g_state.fogRadius = kept.fogRadius; + g_state.bloomThreshold = kept.bloomThreshold; + g_state.bloomStrength = kept.bloomStrength; + g_state.engineDrawFramebuffer = kept.engineDrawFramebuffer; + g_state.engineReadFramebuffer = kept.engineReadFramebuffer; + g_state.context = context; + gl::discard_errors(); + if (!create_shared_resources() || !gl::check_error("world post shared resources")) { + return false; + } + } + if (!(viewport == g_state.viewport)) { + gl::discard_errors(); + if (!create_sized_resources(viewport) || !gl::check_error("world post targets")) return false; + g_logNextFrame.store(true, std::memory_order_relaxed); + } + return true; +} +} // namespace + +void world_post_configure(const core::EngineConfig& cfg) noexcept { + g_state.softFog = cfg.softFogOfWar; + g_state.bloom = cfg.bloom; + g_state.fogRadius = cfg.softFogRadius; + g_state.bloomThreshold = cfg.bloomThreshold; + g_state.bloomStrength = cfg.bloomStrength; +} + +bool world_post_active() noexcept { + return !g_state.failed && (g_state.softFog || g_state.bloom); +} + +bool world_post_before_fog(float viewWorldWidth) noexcept { + const auto& fn = gl::get_gl_functions(); + if (!fn.postProcessAvailable) { + fail("the GL context lacks framebuffer or vertex-array support"); + return false; + } + fn.glGetIntegerv(gl::DRAW_FRAMEBUFFER_BINDING, &g_state.engineDrawFramebuffer); + fn.glGetIntegerv(gl::READ_FRAMEBUFFER_BINDING, &g_state.engineReadFramebuffer); + + bool capturing = false; + { + core::GlStateGuard textures({0}); + core::GlPassGuard pass; + const int* viewport = pass.viewport(); + if (viewport[0] != 0 || viewport[1] != 0 || viewport[2] <= 0 || viewport[3] <= 0) { + fail("the engine viewport is not a full-window rectangle at the origin"); + } else if (!ensure_resources({viewport[2], viewport[3]})) { + fail("GL resources could not be created"); + } else { + g_state.pixelsPerWorldPixel = + game::pixels_per_world_pixel(static_cast(viewport[2]), viewWorldWidth); + set_pass_state(); + if (g_state.softFog) { + // White = "nothing hidden". The engine's alpha-blended black fog then + // leaves, per pixel, the fraction of the scene that stays visible. + fn.glBindFramebuffer(gl::FRAMEBUFFER, g_state.fogCapture.framebuffer); + fn.glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + fn.glClear(gl::COLOR_BUFFER_BIT); + capturing = true; + } + if (g_logNextFrame.exchange(false, std::memory_order_relaxed)) { + try { + LOG_INFO( + "World post: softFog={} (radius {} world px), bloom={}, viewport {}x{}, " + "{:.3f} px per world px, engine framebuffer draw={} read={}", + g_state.softFog, g_state.fogRadius, g_state.bloom, viewport[2], viewport[3], + g_state.pixelsPerWorldPixel, g_state.engineDrawFramebuffer, + g_state.engineReadFramebuffer); + } catch (...) { + } + } + } + bind_engine_framebuffer(); + } + // The guards have restored the engine's state; hand it our target for the + // fog draw only. Framebuffer bindings are not part of the engine's cache. + if (capturing) fn.glBindFramebuffer(gl::FRAMEBUFFER, g_state.fogCapture.framebuffer); + return capturing; +} + +void world_post_after_fog() noexcept { + const auto& fn = gl::get_gl_functions(); + core::GlStateGuard textures({0}); + core::GlPassGuard pass; + bind_engine_framebuffer(); + set_pass_state(); + fn.glViewport(0, 0, g_state.viewport.width, g_state.viewport.height); + // framebuffer = framebuffer * visible fraction + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::DST_COLOR, gl::ZERO); + draw_composite(g_state.fogCapture.texture, 1.0f, 0.0f); + if (!g_state.fogDrawnOnce) { + g_state.fogDrawnOnce = true; + if (!gl::check_error("world post fog composite")) { + fail("the fog composite raised a GL error"); + } else { + try { + LOG_INFO("World post: first fog composite drawn"); + } catch (...) { + } + } + } +} + +void world_post_on_area_load() noexcept { g_logNextFrame.store(true, std::memory_order_relaxed); } + +void world_post_forget() noexcept { + const State kept = g_state; + g_state = {}; + g_state.softFog = kept.softFog; + g_state.bloom = kept.bloom; + g_state.fogRadius = kept.fogRadius; + g_state.bloomThreshold = kept.bloomThreshold; + g_state.bloomStrength = kept.bloomStrength; +} +} // namespace iee::features +``` + +Notes for the implementer: +- `fogDrawnOnce` is reset when the context changes (the whole `State` is rebuilt), which is intended. +- `fail()` inside `world_post_before_fog` still falls through to `bind_engine_framebuffer()`, so a failure never leaves our target bound. +- If `fail()` fires inside `world_post_after_fog`, the frame has already been composited; later frames skip the effect. +- If `gl::RGBA` / `gl::RGBA8` / `gl::UNSIGNED_BYTE` constants are declared with different spellings in `opengl_types.h`, use the existing ones. + +Add to `CMakeLists.txt` in the DLL source list after `src/iee/features/tile_render.cpp`: + +```cmake + src/iee/features/world_post.cpp +``` + +- [ ] **Step 3: Wire the hook** + +`src/iee/hooks.cpp`: + +Add the include next to the other feature include: + +```cpp +#include "iee/features/world_post.h" +``` + +After the `g_vidCellFrameSizeHook` declaration: + +```cpp +// CInfinity::RenderFog(CVidMode*, CVisibilityMap*) and the GL backend's +// argument-less queue flush, used together by the world post passes. +using RenderFogFn = void (*)(void*, void*, void*); +using DrawFlushFn = void (*)(); +static core::Hook g_renderFogHook; +static DrawFlushFn g_drawFlush = nullptr; +``` + +Before `detour_static_render`: + +```cpp +// World post passes (soft fog of war, bloom). The engine queues every draw +// and submits the queue in DrawFlush_GL at the end of the frame, so the world +// only exists in the framebuffer once we flush, and the fog only lands in our +// target if we flush again while it is bound. With both effects off this +// detour is a plain pass-through: no flush, the frame is the engine's own. +static void detour_render_fog(void* infinity, void* vidMode, void* visibility) { + const auto original = g_renderFogHook.original(); + if (!g_ctx || !g_drawFlush || !features::world_post_active()) { + original(infinity, vidMode, visibility); + return; + } + float viewWorldWidth = 0.0f; + if (const auto* activeArea = g_ctx->activeArea.load()) { + area::ViewTransform view{}; + if (area::read_view_transform(activeArea, view)) viewWorldWidth = view.viewWorldW; + } + g_drawFlush(); + const bool capturing = features::world_post_before_fog(viewWorldWidth); + original(infinity, vidMode, visibility); + if (capturing) { + g_drawFlush(); + features::world_post_after_fog(); + } +} +``` + +In `install_all`, after the `if (ctx.cfg.smoothSpriteMovement) { ... }` block and before `g_loadAreaHook.enable();`: + +```cpp + if (ctx.addrs.RenderFog && ctx.addrs.DrawFlush) { + try { + features::world_post_configure(ctx.cfg); + g_drawFlush = reinterpret_cast(ctx.addrs.DrawFlush); + g_renderFogHook.create(reinterpret_cast(ctx.addrs.RenderFog), + reinterpret_cast(&detour_render_fog)); + g_renderFogHook.enable(); + LOG_INFO("CInfinity::RenderFog hook installed (soft fog of war={}, bloom={})", + ctx.cfg.softFogOfWar, ctx.cfg.bloom); + } catch (...) { + g_drawFlush = nullptr; + (void)g_renderFogHook.remove(); + LOG_WARN("CInfinity::RenderFog hook failed; fog of war and bloom stay vanilla"); + } + } +``` + +In `uninstall_all`, before `g_animationInterpActive = false;`: + +```cpp + (void)g_renderFogHook.remove(); + g_drawFlush = nullptr; + features::world_post_forget(); +``` + +In `prepare_for_shutdown`, as the first statement: + +```cpp + (void)g_renderFogHook.disable(); +``` + +In `detour_load_area`, next to where `g_spriteMotionReset` is raised: + +```cpp + features::world_post_on_area_load(); +``` + +- [ ] **Step 4: Build** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure` +Expected: `All InfinityEngine-Enhancer native tests passed`. + +Run: `cmake --build build-mingw` +Expected: builds with no warnings. + +- [ ] **Step 5: Commit and produce the bundle** + +```bash +git add src/iee/features/world_post.h src/iee/features/world_post.cpp src/iee/hooks.cpp CMakeLists.txt +git commit -m "World post: capture the engine fog through our own target" +git push -u origin feature/world-post +``` + +Open a draft PR against `feature/are-animation-detection` so CI builds the bundle. (Pushes go through the 1Password SSH agent; an unanswered approval prompt shows up as `Permission denied (publickey)`.) + +- [ ] **Step 6: Owner in-game check (blocking)** + +INI: `[Rendering] SoftFogOfWar = true`, `SoftFogRadius = 0`, `Bloom = false`. Compare against `SoftFogOfWar = false` in the same spot. + +Checklist: +1. Log shows `CInfinity::RenderFog hook installed`, one `World post: softFog=true …` line per area, and `first fog composite drawn`. No `disabled for this session` line. +2. Fog of war looks the same as vanilla: same shape, same darkness for unexplored and explored-but-unseen. +3. Characters behind walls are still hidden/dithered correctly; nothing draws over the UI or vanishes behind the world. +4. Rain or snow still draws (AR2600 area in bad weather, or any outdoor area). +5. A fully explored interior shows no darkening at all. +6. A screenshot (the game's own key) looks like the screen. +7. Resize the window or change resolution: fog still lines up. +8. Save and load, change area: no stale fog, log re-arms. + +If item 3 fails, stop: the offline depth finding in Task 1 is contradicted, so report the symptom before going further. If any other item fails, fix before Task 7. + +--- + +### Task 7: Soft fog (blur chain) + +**Files:** +- Modify: `src/iee/features/world_post.cpp` + +**Interfaces:** +- Consumes: `game::BlurPlan`, `blur_plan_for_radius`, `half_extent`, `available_blur_levels`, `kMaxBlurLevels` (Task 2); everything from Task 6. +- Produces (file-local, reused by Task 8): + - `struct BlurChain { std::array levels{}; int count{}; };` + - `bool create_chain(BlurChain& chain, game::Extent base) noexcept;` — level `i` is `base` halved `i + 1` times, `RGBA16F` + - `void destroy_chain(BlurChain& chain) noexcept;` + - `void run_blur(const Target& source, BlurChain& chain, game::BlurPlan plan, bool accumulate) noexcept;` — result in `chain.levels[0]`; leaves an arbitrary framebuffer bound; requires pass state set + +- [ ] **Step 1: Add the blur programs and chain** + +In `world_post.cpp`, add `#include ` and, after `kCompositeSource`: + +```cpp +// Dual-filter blur (down then up through half-size levels). uStep is the +// sample spread in source UV units. +constexpr const char* kBlurDownSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform vec2 uStep; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec4 sum = texture(uTexture, vUv) * 4.0; + sum += texture(uTexture, vUv - uStep); + sum += texture(uTexture, vUv + uStep); + sum += texture(uTexture, vUv + vec2(uStep.x, -uStep.y)); + sum += texture(uTexture, vUv - vec2(uStep.x, -uStep.y)); + fragColor = sum / 8.0; +} +)glsl"; + +constexpr const char* kBlurUpSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform vec2 uStep; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec4 sum = texture(uTexture, vUv + vec2(-uStep.x * 2.0, 0.0)); + sum += texture(uTexture, vUv + vec2(-uStep.x, uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(0.0, uStep.y * 2.0)); + sum += texture(uTexture, vUv + vec2(uStep.x, uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(uStep.x * 2.0, 0.0)); + sum += texture(uTexture, vUv + vec2(uStep.x, -uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(0.0, -uStep.y * 2.0)); + sum += texture(uTexture, vUv + vec2(-uStep.x, -uStep.y)) * 2.0; + fragColor = sum / 12.0; +} +)glsl"; +``` + +After `struct CompositeProgram`: + +```cpp +struct BlurProgram { + unsigned id{}; + int step{-1}; +}; + +struct BlurChain { + std::array levels{}; + int count{}; +}; +``` + +Add to `State`, after `Target fogCapture{};`: + +```cpp + BlurProgram blurDown{}; + BlurProgram blurUp{}; + BlurChain fogChain{}; +``` + +After `create_target`: + +```cpp +void destroy_chain(BlurChain& chain) noexcept { + for (auto& level : chain.levels) destroy_target(level); + chain.count = 0; +} + +bool create_chain(BlurChain& chain, game::Extent base) noexcept { + destroy_chain(chain); + const int count = game::available_blur_levels(base); + game::Extent extent = base; + for (int index = 0; index < count; ++index) { + extent = game::half_extent(extent); + if (!create_target(chain.levels[static_cast(index)], extent, gl::RGBA16F, + gl::HALF_FLOAT)) { + destroy_chain(chain); + return false; + } + } + chain.count = count; + return true; +} + +void blur_pass(const BlurProgram& program, const Target& source, const Target& destination, + float stepTexels) noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glBindFramebuffer(gl::FRAMEBUFFER, destination.framebuffer); + fn.glViewport(0, 0, destination.extent.width, destination.extent.height); + fn.glUseProgram(program.id); + fn.glUniform2f(program.step, stepTexels / static_cast(source.extent.width), + stepTexels / static_cast(source.extent.height)); + fn.glBindTexture(gl::TEXTURE_2D, source.texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); +} + +// Blurs `source` into chain.levels[0]. With `accumulate`, each level keeps its +// own downsampled content and the wider blur is added on top (bloom); without +// it the result is a plain blur (fog). Requires set_pass_state(). +void run_blur(const Target& source, BlurChain& chain, game::BlurPlan plan, + bool accumulate) noexcept { + const auto& fn = gl::get_gl_functions(); + const int levels = plan.levels < chain.count ? plan.levels : chain.count; + if (levels <= 0) return; + fn.glDisable(gl::BLEND); + const Target* from = &source; + for (int index = 0; index < levels; ++index) { + const auto& to = chain.levels[static_cast(index)]; + blur_pass(g_state.blurDown, *from, to, plan.offset); + from = &to; + } + if (accumulate) { + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::ONE, gl::ONE); + } + for (int index = levels - 1; index > 0; --index) { + blur_pass(g_state.blurUp, chain.levels[static_cast(index)], + chain.levels[static_cast(index - 1)], plan.offset * 0.5f); + } + fn.glDisable(gl::BLEND); +} +``` + +In `create_shared_resources`, before `return true;`: + +```cpp + const auto build_blur = [&](BlurProgram& program, const char* source) { + program.id = build_program(source); + if (!program.id) return false; + program.step = fn.glGetUniformLocation(program.id, "uStep"); + fn.glUseProgram(program.id); + fn.glUniform1i(fn.glGetUniformLocation(program.id, "uTexture"), 0); + return true; + }; + if (!build_blur(g_state.blurDown, kBlurDownSource)) return false; + if (!build_blur(g_state.blurUp, kBlurUpSource)) return false; +``` + +In `create_sized_resources`, after the fog capture is created: + +```cpp + if (!create_chain(g_state.fogChain, viewport)) return false; +``` + +- [ ] **Step 2: Use the blur in the fog composite** + +Replace the body of `world_post_after_fog` from `bind_engine_framebuffer();` through the `draw_composite(...)` call with: + +```cpp + set_pass_state(); + const auto plan = game::blur_plan_for_radius(g_state.fogRadius * g_state.pixelsPerWorldPixel, + g_state.viewport); + const bool blurred = plan.levels > 0 && g_state.fogChain.count > 0; + if (blurred) run_blur(g_state.fogCapture, g_state.fogChain, plan, false); + bind_engine_framebuffer(); + fn.glViewport(0, 0, g_state.viewport.width, g_state.viewport.height); + // framebuffer = framebuffer * visible fraction + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::DST_COLOR, gl::ZERO); + draw_composite(blurred ? g_state.fogChain.levels[0].texture : g_state.fogCapture.texture, 1.0f, + blurred ? 1.0f : 0.0f); +``` + +Extend the once-per-area `LOG_INFO` in `world_post_before_fog` so the plan is visible: compute `const auto plan = game::blur_plan_for_radius(g_state.fogRadius * g_state.pixelsPerWorldPixel, {viewport[2], viewport[3]});` just before the log and append `, fog blur levels={} offset={:.2f}` with `plan.levels, plan.offset`. + +- [ ] **Step 3: Build** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure && cmake --build build-mingw` +Expected: tests pass; mingw builds with no warnings. + +- [ ] **Step 4: Commit and push** + +```bash +git add src/iee/features/world_post.cpp +git commit -m "World post: soft fog of war edge" +git push +``` + +- [ ] **Step 5: Owner in-game check (blocking)** + +INI: `SoftFogOfWar = true`, `SoftFogRadius = 24`. + +1. The fog edge is a smooth gradient with no diamond or stair-step shapes. +2. Zoom fully in and fully out, changing area or reloading between them so the log line prints again: the logged `px per world px` differs between the two, and the edge softness looks proportionate at both. (If the value does not change with zoom, the view transform is stale at this point of the frame; read the zoom from `CInfinity::m_fZoom` at `+0x484` on the hook's `infinity` argument instead, via a manifest offset.) +3. No banding in the dark gradient; no light leaking in from unexplored areas beyond a few pixels. +4. `SoftFogRadius = 0` still matches vanilla. +5. Try 12 and 48 and report the preferred value; update the default in `config.h`, the Task 3 test and the spec if it is not 24. + +--- + +### Task 8: Bloom + +**Files:** +- Modify: `src/iee/features/world_post.cpp` + +**Interfaces:** +- Consumes: `BlurChain`, `create_chain`, `destroy_chain`, `run_blur`, `draw_composite`, `set_pass_state`, `bind_engine_framebuffer` (Tasks 6–7); `game::bloom_levels`, `game::half_extent` (Task 2). +- Produces: bloom drawn inside `world_post_before_fog`; no interface change for callers. + +- [ ] **Step 1: Add the bright-pass program and bloom targets** + +After `kBlurUpSource`: + +```cpp +// Keeps what is brighter than uThreshold, with a soft knee so the cut-off +// does not flicker. +constexpr const char* kBrightSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform float uThreshold; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec3 color = texture(uTexture, vUv).rgb; + float level = max(color.r, max(color.g, color.b)); + const float knee = 0.1; + float soft = clamp(level - uThreshold + knee, 0.0, 2.0 * knee); + soft = soft * soft / (4.0 * knee); + float weight = max(soft, level - uThreshold) / max(level, 1e-4); + fragColor = vec4(color * weight, 1.0); +} +)glsl"; +``` + +After `struct BlurProgram`: + +```cpp +struct BrightProgram { + unsigned id{}; + int threshold{-1}; +}; +``` + +Add to `State`, after `BlurChain fogChain{};`: + +```cpp + BrightProgram bright{}; + Target bloomScene{}; // half-size copy of the world + Target bloomBright{}; // its bright parts + BlurChain bloomChain{}; + bool bloomDrawnOnce{}; +``` + +In `create_shared_resources`, before `return true;`: + +```cpp + g_state.bright.id = build_program(kBrightSource); + if (!g_state.bright.id) return false; + g_state.bright.threshold = fn.glGetUniformLocation(g_state.bright.id, "uThreshold"); + fn.glUseProgram(g_state.bright.id); + fn.glUniform1i(fn.glGetUniformLocation(g_state.bright.id, "uTexture"), 0); +``` + +In `create_sized_resources`, after the fog chain: + +```cpp + const auto half = game::half_extent(viewport); + if (!create_target(g_state.bloomScene, half, gl::RGBA16F, gl::HALF_FLOAT)) return false; + if (!create_target(g_state.bloomBright, half, gl::RGBA16F, gl::HALF_FLOAT)) return false; + if (!create_chain(g_state.bloomChain, half)) return false; +``` + +- [ ] **Step 2: Add the bloom pass** + +After `run_blur`: + +```cpp +// Adds a glow of the bright parts of the world image to the engine's +// framebuffer. Runs before the fog so unexplored areas stay dark, and before +// the UI is drawn so the UI cannot glow. Requires set_pass_state(). +void draw_bloom() noexcept { + const auto& fn = gl::get_gl_functions(); + const auto half = g_state.bloomScene.extent; + const int levels = game::bloom_levels(half); + if (levels <= 0 || !(g_state.bloomStrength > 0.0f)) return; + + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, + static_cast(g_state.engineDrawFramebuffer)); + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, g_state.bloomScene.framebuffer); + fn.glBlitFramebuffer(0, 0, g_state.viewport.width, g_state.viewport.height, 0, 0, half.width, + half.height, gl::COLOR_BUFFER_BIT, gl::LINEAR); + + fn.glBindFramebuffer(gl::FRAMEBUFFER, g_state.bloomBright.framebuffer); + fn.glViewport(0, 0, half.width, half.height); + fn.glUseProgram(g_state.bright.id); + fn.glUniform1f(g_state.bright.threshold, g_state.bloomThreshold); + fn.glBindTexture(gl::TEXTURE_2D, g_state.bloomScene.texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + + run_blur(g_state.bloomBright, g_state.bloomChain, {levels, 1.0f}, true); + + bind_engine_framebuffer(); + fn.glViewport(0, 0, g_state.viewport.width, g_state.viewport.height); + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::ONE, gl::ONE); + // Each level adds its share on the way up; normalise so strength means the + // same thing at every resolution. + draw_composite(g_state.bloomChain.levels[0].texture, + g_state.bloomStrength / static_cast(levels), 0.0f); + fn.glDisable(gl::BLEND); + + if (!g_state.bloomDrawnOnce) { + g_state.bloomDrawnOnce = true; + if (!gl::check_error("world post bloom")) { + fail("the bloom pass raised a GL error"); + } else { + try { + LOG_INFO("World post: first bloom drawn ({} levels)", levels); + } catch (...) { + } + } + } +} +``` + +In `world_post_before_fog`, right after `set_pass_state();` and before the `if (g_state.softFog)` block: + +```cpp + if (g_state.bloom) { + draw_bloom(); + set_pass_state(); + } +``` + +(`set_pass_state()` is repeated because `draw_bloom` leaves blending and bindings in its own state; the fog clear that follows needs the baseline.) + +A multisampled default framebuffer makes the scaled `glBlitFramebuffer` raise `GL_INVALID_OPERATION`; the first-frame error check then latches the feature off with a log line, which is the intended fail-closed behaviour. + +- [ ] **Step 3: Build** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure && cmake --build build-mingw` +Expected: tests pass; mingw builds with no warnings. + +- [ ] **Step 4: Commit and push** + +```bash +git add src/iee/features/world_post.cpp +git commit -m "World post: threshold bloom on the world image" +git push +``` + +- [ ] **Step 5: Owner in-game check (blocking)** + +INI: `Bloom = true`, defaults otherwise; test with `SoftFogOfWar` both on and off. + +1. Log shows `first bloom drawn`; no `disabled for this session`. +2. Fires, torches, spell effects and lit windows glow. +3. UI panels, text, portraits and the cursor do not glow. +4. Unexplored (black) areas show no glow leaking over them. +5. A bright daytime outdoor map and a snow map are not hazy at the defaults. +6. With `Bloom = true` and `SoftFogOfWar = false` the fog looks vanilla (this is the bloom-only path: one flush, engine fog untouched). +7. Tune `BloomThreshold` (try 0.7 and 0.9) and `BloomStrength` (try 0.2 and 0.6); report preferred values and update the defaults in `config.h`, the Task 3 test and the spec if they differ. + +--- + +### Task 9: Runtime toggle and documentation + +**Files:** +- Modify: `src/iee/features/world_post.h`, `src/iee/features/world_post.cpp`, `src/iee/hooks.cpp` +- Modify: `docs/superpowers/specs/2026-10-03-graphics-uplift-roadmap-design.md` (items D1/D2, §3 order), `docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md` (note on phases 4–5), `CLAUDE.md` (Runtime Facts — Renderer / GL), `../handoff.md` + +**Interfaces:** +- Produces: `void features::world_post_poll_hotkeys() noexcept;` — render thread; F8 toggles soft fog, F9 toggles bloom. + +- [ ] **Step 1: Add the hotkey poll** + +`world_post.h`, after `world_post_active`: + +```cpp +// Debug A/B: F8 toggles soft fog of war, F9 toggles bloom. Call once per +// frame, only when debug hotkeys are enabled. +void world_post_poll_hotkeys() noexcept; +``` + +`world_post.cpp`, after `world_post_active`: + +```cpp +void world_post_poll_hotkeys() noexcept { + static bool fogKeyWasDown = false; + static bool bloomKeyWasDown = false; + const bool fogKeyDown = (GetAsyncKeyState(VK_F8) & 0x8000) != 0; + const bool bloomKeyDown = (GetAsyncKeyState(VK_F9) & 0x8000) != 0; + const bool fogPressed = fogKeyDown && !fogKeyWasDown; + const bool bloomPressed = bloomKeyDown && !bloomKeyWasDown; + fogKeyWasDown = fogKeyDown; + bloomKeyWasDown = bloomKeyDown; + if (!fogPressed && !bloomPressed) return; + if (fogPressed) g_state.softFog = !g_state.softFog; + if (bloomPressed) g_state.bloom = !g_state.bloom; + try { + LOG_INFO("Hotkey: soft fog of war={}, bloom={}", g_state.softFog, g_state.bloom); + } catch (...) { + } +} +``` + +`hooks.cpp`, as the first statements of `detour_render_fog` after `const auto original = ...;`: + +```cpp + if (g_ctx && g_ctx->cfg.enableDebugHotkeys) features::world_post_poll_hotkeys(); +``` + +- [ ] **Step 2: Build** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure && cmake --build build-mingw` +Expected: tests pass; mingw builds with no warnings. + +- [ ] **Step 3: Update the documents** + +- `CLAUDE.md`, under "Runtime Facts — Renderer / GL", add: + +```markdown +- The engine queues every draw (`DrawEnd_GL` only appends to `gl.cmds`) and submits the whole frame in `DrawFlush_GL` (2.7.3 RVA `0x42B350`), normally once at flip. Binding a framebuffer around an engine render function captures nothing, and an immediate GL draw of ours lands under the still-queued world. Flush first (see `features/world_post.*`), and restore every GL state touched: the flush issues changes relative to the engine's cached `gl.hwState`. +- `gl.pp.enabled` is forced false in 2.7.3 (`DrawInit_GL`): the engine's own offscreen target and `fpCatRom` are dead. +``` + +- `2026-10-03-graphics-uplift-roadmap-design.md`: in item D, mark D1 and D2 implemented behind `[Rendering] SoftFogOfWar` / `Bloom` with a pointer to the 2026-10-07 spec; in §3, move D1/D2 to "Done" and note that F's pack work is paused by owner decision 2026-10-07. +- `2026-06-10-graphics-enhancement-roadmap-design.md`: at the top of "Phase 4 — Fog" add one line: "Superseded by `2026-10-07-soft-fog-and-bloom-design.md`: the engine queues draws, so an FBO bracket around `RenderFog` captures nothing." +- `../handoff.md`: update the date, the State table (soft fog and bloom rows with their in-game status; animation in-betweens pack marked paused), replace "Phase 1" as next step with effect-cast light, and add the queue/flush fact to Working notes. + +- [ ] **Step 4: Commit and push** + +```bash +git add src/iee/features/world_post.h src/iee/features/world_post.cpp src/iee/hooks.cpp CLAUDE.md docs/superpowers/specs +git commit -m "World post: debug A/B hotkeys; record the engine draw queue in the docs" +git push +``` + +(`../handoff.md` lives outside the repository; edit it in place.) + +- [ ] **Step 5: Final verification** + +Run: `cmake --build build-host --target iee_tests && ctest --test-dir build-host --output-on-failure && cmake --build build-mingw` +Expected: tests pass; mingw builds with no warnings. Confirm CI is green on the PR, and that with both settings `false` and hotkeys off the log shows no `RenderFog hook installed` line (addresses are not even resolved). diff --git a/docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md b/docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md index 2b41559..209b11d 100644 --- a/docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md +++ b/docs/superpowers/specs/2026-06-10-graphics-enhancement-roadmap-design.md @@ -310,6 +310,9 @@ New infrastructure: ### Phase 4 — Fog (P3) +> Superseded by `2026-10-07-soft-fog-and-bloom-design.md`: the engine queues +> its draws, so an FBO bracket around `RenderFog` captures nothing. + - Bracket architecture: pre-`RenderFog` bind an R8 FBO sized to the viewport; call the original untouched (`BltFogOWar3d`/`DrawVisible` render into our target); post: separable blur (radius scaled by `m_fZoom`), composite over the diff --git a/docs/superpowers/specs/2026-10-03-graphics-uplift-roadmap-design.md b/docs/superpowers/specs/2026-10-03-graphics-uplift-roadmap-design.md new file mode 100644 index 0000000..2d64a65 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-graphics-uplift-roadmap-design.md @@ -0,0 +1,314 @@ +# Graphics Uplift Roadmap — Feasibility and Order + +Date: 2026-10-03 +Status: Feasibility record and ordering. Each item still needs its own spec +before implementation. +Target: BGEE 2.7.3.x (x64, Proton), EEex-loaded DLL, EEex Uncap FPS enabled. + +This document supersedes the *scope decisions* of +[2026-06-10-graphics-enhancement-roadmap-design.md](2026-06-10-graphics-enhancement-roadmap-design.md) +where they conflict (see [Reversed decisions](#reversed-decisions)). The +architecture rules in that document and in `docs/architecture.md` still hold: +manifest-only addresses, GL state guard, fail closed, shader delivery through +the game `override/` directory plus DLL uniform feeding. + +## 1. Evidence base + +All RVAs below are for the installed Steam `Baldur.exe` 2.7.3. Its CodeView +record (GUID `B671EED8-BE5E-4979-A639-3E707E611587`, age 75) matches +`IE_2.7.3.0_Win64_debug/Baldur.pdb`, so PDB symbols are authoritative for this +build. Decompiles came from a headless Ghidra project with the PDB applied +(location recorded in the workspace `AGENTS.md`). Everything here is static +analysis; items marked **gate** need DLL-side logging before they are relied on. + +### 1.1 Timing + +| Fact | Source | +|---|---| +| Logic ticks at 30 Hz | `CChitin::TIMER_UPDATES_PER_SECOND` (RVA `0x65CC5C`) = 30 | +| VEF/VVC frame pacing is derived from 30 | `CVisualEffect::MAX_FRAME_RATE` (RVA `0x59EC20`) = 30, used in `CVEFVidCell::FrameAdvance` `0x254440` | +| Rendering runs faster than logic | EEex Uncap FPS (`CChitin::Override_Update`), limit 240 in the local config | +| EEex smooths only the viewport | `EEex_UncapFPS_Patch.lua` hooks scroll, zoom, screen shake, tooltips; no sprite hooks. `EEex.dll` exports no tick-progress value | +| EEex also detours `CVidTile::RenderTexture` | `Hook-CVidTile::RenderTexture()-FirstInstruction` — same function this mod hooks | + +### 1.2 Sprite and effect draw path + +Every paletted BAM frame is rebuilt on the CPU on every rendered frame: + +``` +CVidCell::Render3d (0x424780 / 0x424AE0) + GetFrame (vtable, 0x4118A0) m_nCurrentSequence/m_nCurrentFrame -> m_pFrame + CVidPalette::Realize -> rgbTempPal live palette (tints, range affects) applied here + DrawLockSurface (GL: 0x42CAF0) slot in the streaming atlas fx[0] nearest / fx[1] linear + CVidCell::Blt8To32 (0x426C00) 8-bit -> RGBA into the atlas texels + CVidCell::RenderTexture (0x425530) blend mode from flags, DrawQuad +``` + +Characters and effects composite several cells into one surface first: + +``` +CInfinity::FXPrep 0x29E1B0 -> FXLock 0x29E190 + FXRender 0x29E260 per cell body, helmet, shield, weapon (order by facing) + FXRenderClippingPolys 0x29E4C0 wall polygons rasterised into the surface +FXUnlock 0x29EA40 -> FXBltFromClipped 0x29DFF0 -> CVidMode::FXBltToBack +``` + +Consequences: + +- The GPU receives one RGBA image per sprite with the live palette already + applied. Runtime palette mutation is therefore **not** an obstacle for + anything done at or after `Blt8To32`. +- Sprites share a streaming atlas. Any shader kernel that samples neighbours + must clamp to the quad's own rectangle. +- BAM v2 (PVRZ-backed) frames take `CVidCell::RenderPVR` (`0x4250D0`) instead + and skip the CPU composite. +- The sprite shadow is one palette index (`CVidPalette::SHADOW_ENTRY`), + resolved in `Render3d` before the blit. +- Wall occlusion can use dithered spans + (`CVidPoly::DrawHLineDithered32` `0x42DB50`, "Always Dither" option). + +Per-object render entry points that route into the path above: + +| Object | Function | RVA | +|---|---|---| +| Creature | `CGameSprite::Render` | `0x36BA50` | +| Character animation | `CGameAnimationTypeCharacter::Render` (+16 sibling types) | `0x32C240` | +| VEF/VVC effect cell | `CVEFVidCell::Render` | `0x254B00` | +| Effect animation | `CGameAnimationTypeEffect::Render` | `0x32D020` | +| Projectile | `CProjectileBAM::Render` | `0x233F20` | +| Fireball | `CGameFireball3d::Render` | `0x1EAF90` | +| Ambient static | `CGameStatic::Render` (already hooked) | `0x1F27D0` | +| Particles (GL points) | `CParticle::Render` | `0x425BE0` | +| Rain / snow | `CRainStorm::Render` / `CSnowStorm::Render` | `0x25CD20` / `0x25CDD0` | + +### 1.3 Frame composition order + +`CGameArea::Render` (`0x189360`): `CInfinity::Render` (tiles) → +`RenderEdgeFade` → object lists (virtual `Render`) → `RenderTransitions` → +`RenderAOE` → `RenderFog` → `PostRender`. UI draws afterwards, so a bracket +around this function excludes the UI by construction. + +`CInfinity::RenderFog` (`0x2A1B60`) draws fog as untextured, alpha-blended +triangles per 64 px tile inside one `DrawBegin`/`DrawEnd`. It is safe to +bracket; `CVisibilityMap::BltFogOWar3d` (`0x257520`) must still never be +detoured. + +### 1.4 Engine light value + +`CInfinity::Render` multiplies every tile by `m_rgbGlobalLighting` +(`GetGlobalLighting` `0x29F890`: time-of-day, overcast and lightning colours +combined) and hands the same colour to sprites via `CVidMode::rgbGlobalTint`. +`CGameArea::GetTintColor` (`0x182ED0`) adds the per-position lightmap tint for +sprites. This is a flat colour, not a light simulation. + +### 1.5 Engine shaders + +`chitin.key` lists `FPSPRITE`, `FPCRSPRT`, `fpDraw`, `fpSeam`, `FPSELECT`, +`FPFONT`, `fpTone`, `fpYUV`, `FPYUVGRY`, `fpCatRom`, `vpDraw`, `vpBlit`, +`vpYUV` (and `FPIT1L/M/S`). Originals are extractable from the BIFs for +interface contracts. Extracted sources are Beamdog content and stay out of +this repository. + +## 2. Items + +Each item lists what it does, where it attaches, and the main risk. + +### A. Position smoothing + +Sprites move in 30 Hz steps while the camera scrolls at display rate. Lerp +each sprite's draw position between ticks inside a `CGameSprite::Render` hook +(swap position in, call original, restore). + +- Implemented 2026-10-03 behind `[Rendering] SmoothSpriteMovement` (default + off, 2.7.3 only); in-game validation pending. +- No engine "previous position" is used: `SpriteMotionTracker` observes each + sprite's `m_pos` at render time, treats a change as a logic tick, and slides + from the last shown position over the interval between the last two + changes (30 Hz assumed until observed). No tick hook is needed. +- `CGameSprite::RenderMarkers` (`0x36F170`) and `RenderHealthBar` + (`0x36E820`) are wrapped with the same swap and share one per-frame + timestamp, so circles and bars stay glued to the sprite. +- In-game result 2026-10-03 (owner): works, visibly smoother. The field log + shows sprite renders and `LoadArea` on the same thread, so the position + swap cannot race the logic tick. +- Moving non-creature objects use the same swap through a manifest table + (`BuildManifest::smoothedObjectRenders`), with their own tracker and a + larger snap limit: `CProjectileBAM`, `CProjectileScorcher`, + `CProjectileNewScorcher`, `CProjectileSkyStrike`, `CGameFireball3d`, + `CGameTemporal`, `CGameChunk`, `CVEFVidCell`. Each was confirmed to draw + from `CGameObject::m_pos`; `CProjectileSkyStrikeBAM` does not and is left + out. The tracker also starts over for a key not sampled for 0.5 s. +- Known limits: positions are whole world pixels; animation frames + themselves are not interpolated (item F); anything else that reads `m_pos` + during + render (floating text, action icons drawn outside the three hooks) is not + smoothed. + +### B. `fpSprite` replacement + +One override shader that covers every character and effect quad. + +- B1 sharpening kernel. Owner decision 2026-10-04: an FSR-family filter. + Only the spatial part applies (FSR1: edge-adaptive upscale plus + sharpening). FSR2/3 and other temporal upscalers do not fit, because a + pre-rendered sprite frame yields no new samples over time. The aim is clean + edges when the game magnifies sprites, not added detail. +- B2 premultiplied-alpha halo fix (all three parts together, per the 2026-06-10 + design §4.8). +- B3 soft shadows: tag `SHADOW_ENTRY` pixels at blit time, blur in shader. +- B4 smooth wall occlusion: replace the dithered stipple with alpha. +- Risk: atlas neighbour bleed; **gate** on per-quad UV rectangle availability. +- Rejected 2026-10-04: offline AI upscaling of sprites. Real-ESRGAN (general, + anime and anime-video models) and SeedVR 7B (owner's test) smooth the + sprites without adding real detail; a 25-pixel-wide figure does not hold + enough to reconstruct. + +### C. Effect replacement framework + +Hook the per-object render, look the resref up in a table, then pass through +or suppress and draw the replacement. The hook seam is the shipped +`CGameStatic::Render` pattern, generalised. + +Where the replacement is drawn is a separate choice. Today fire and smoke are +painted inside `fpSEAM` (the tile pass), which has structural limits: they +sit under every object, are multiplied by the global light colour, and cost +a per-pixel loop over the point set. Drawing them with our own program at +the engine's final blit (`CVidMode::FXBltToBack` `0x41D540`) would remove +those limits, but needs the engine's internal batch flush pinned first. +Owner decision 2026-10-03: stay in `fpSEAM` for now and fix placement there. + +- C1 fix placement and size of the existing smoke / fire replacements + (current branch): geometry now comes from the BAM frame table, see + `docs/are-animation-detection.md`. +- C2 extend to VEF/VVC, projectiles, fireballs; first batch is the + highest-frequency effects, the table grows from logs of unreplaced resrefs. +- C3 effect-cast light: publish active effect positions into the existing + point set so effects light tiles and sprites. +- C4 particles and weather (`CParticle`, rain, snow) as shader passes. +- **Gate:** resref and world-position field offsets per class (PDB types / + EEex docs → `build_manifest`). +- Risk: "all effects" is content work; it is never finished, only prioritised. + +### D. Offscreen world pass + +D1 and D2 are implemented (2026-10-07) behind `[Rendering] SoftFogOfWar` and +`Bloom`, default off, 2.7.3 only; in-game validation pending. Design and the +engine draw-queue evidence: `2026-10-07-soft-fog-and-bloom-design.md`. The +description below predates that evidence: the engine queues its draws, so +D1 flushes the queue and captures the fog rather than bracketing it. + +- D1 soft fog of war: render `RenderFog` into an offscreen target, blur + (radius scaled by zoom), composite. +- D2 bloom on the world pass only. +- D3 water reflections of sprites and effects. +- Precondition already met: the engine binds no FBOs (gate V5, 2026-06-11). + +### E. Companion textures per tile page + +A sibling texture per PVRZ page, generated offline alongside the upscale. + +- E1 normal maps → per-pixel relighting from the point set (C3). +- E2 wall-polygon occlusion for those lights, and light-directed sprite + shadows. The polygons are the ones `FXRenderClippingPolys` consumes. +- E3 "living maps": foliage sway and window/banner flicker from offline masks. +- Risk: quality of colour-derived normals and segmentation masks on painted + art. Prototype offline on a few areas before any loader work. + +### F. Animation frame in-betweening + +Owner decision 2026-10-03: in-between frames are generated ahead of drawing +with RIFE and stored as expanded BAMs; the engine keeps drawing them itself, +so palettes, recolouring, wall clipping and layering are untouched. + +**Expanded BAM** (`iee-interp/.bam` in the game folder): an +uncompressed BAM V1 with the original palette and cycle count. For an +original cycle `[f0 .. f(L-1)]` the expanded cycle is +`[f0, m(0,1), f1, m(1,2), ..., f(L-1), m(L-1,0)]`: original frame k at 2k, +the in-between leading to k+1 at 2k+1. A slot with no usable in-between +repeats frame k. + +**Draw-time swap** (implemented, `game::AnimationInterpolator`; in-game +validation pending). Logic must keep the original BAM, because it counts +frames from it (`CVidCell::IsEndOfSequence`, one frame per creature update). +So only while `CGameSprite::Render` runs, each `CVidCell` the engine resolves +a frame for is pointed at a private `CResCell` over the expanded image and at +the frame for the current instant; both are restored when the draw returns. + +- Hooks: `CVidCell::GetFrame` (`0x4118A0`), `GetCurrentCenterPoint` + (`0x411660`), `GetCurrentFrameSize` (`0x411780`) — every path that first + reads a cell's frame during a draw goes through one of them. +- Why a private `CResCell` is safe: `CRes::Demand` (`0x3F6D50`) returns + `pData` immediately when `bLoaded` is set, and + `CResCell::GetFrameData` (`0x3F79E0`) only adds the frame offset to + `m_pBamHeader`. The private cell is never in the engine's resource table, + so nothing evicts or frees it. +- Timing: for the first half of the observed animation step after logic + moves k-1 -> k the in-between is shown, then frame k. Half a step of lag, + no guessing ahead. +- A creature is interpolated only if every cell it drew last time has an + expanded BAM (body, weapon, shield and helmet are separate, frame-synced + BAMs; a partial swap would draw the body behind its weapon). +- Gated by `[Rendering] InterpolateAnimations` (default off, 2.7.3 only). + The log names each expanded BAM loaded and each resref that has none. + +**Generation** (prototype only so far, Linux Python in a scratch folder): +RIFE v4.25-heavy through `rife-ncnn-vulkan` in batch mode, colour and +silhouette side by side at 4x, result mapped back to palette indices +restricted to the entries the two source frames use (an unrestricted match +put 17% of pixels on entries from other recolour ranges). Pairs whose +silhouettes overlap too little, and in-betweens whose area drifts from the +pair's mean, are dropped. Measured: about 40 frame pairs per second on an +RTX 4090 including PNG I/O. + +Tested alternatives: RIFE v4.6/v4.25/v4.26 variants (v4.25-heavy best), +IFRNet (worse), plain crossfade (ghosting). GIMM-VFI, FILM and GMFSS need a +PyTorch stack and were not tested. No model handled a limb that crosses its +own length in one step. + +Still to build: the generator as our own Windows program (runs natively and +under Proton, calls the RIFE executable), invoked for the animations an area +needs with a status line on the loading screen; equipment overlay families; +non-creature animations. + +### G. Small polish + +Anti-aliased selection rings / AOE markers / path lines (`fpSELECT`), cutscene +scaling kernel (`fpYUV`), offline upscaling of palette-stable portraits and +item icons. + +## 3. Order + +Revised 2026-10-04 after A and F's engine side were confirmed in game. + +| Phase | Items | Why here | +|---|---|---| +| paused | F: in-betweens-only pack with source fingerprints, in-memory merge, standalone generator, vanilla pack as its own mod; narrow the all-cells rule to frame-synced cells | The owner's favourite feature currently depends on hand-generated whole files | +| 2 | B1, B2 | Sprites are the most visible mismatch against 4x maps | +| 3 | C1 confirmation, C2 (blood first), C3 | Effects plus the light they cast | +| done (unvalidated) | D1, D2 | Built first by owner decision 2026-10-07: larger visual gain than the F pack | +| Later | B3, B4, E1 -> E2 -> E3, C4, D3, G | Polish and the large asset-driven items | + +Done: A (movement smoothing), F's draw-time swap. Owner decision 2026-10-07: +the F pack work is paused; effect-cast light (C3) follows D1/D2. + +## 4. Reversed decisions + +| 2026-06-10 decision | Now | Reason | +|---|---|---| +| Frame interpolation rejected as a content-mod problem (§10.8) | Item F, spike first | Frames are composited on the CPU with the live palette; the palette-mutation objection does not apply at the blit | +| Per-VFX work dropped as disproportionate (§3, §10.7) | Item C | Per-object `Render` hooks give resref + position directly; the pattern already ships for statics | +| Weather polish dropped (§10.7) | Item C4, low priority | Same framework, little extra plumbing | +| Offline ESRGAN rejected for creature sprites (§10.8) | Still rejected | Runtime kernel in `fpSprite` (B1) instead | + +Day/night map blending stays out of scope: the engine holds one tileset at a +time. + +## 5. Standing constraints + +- Every new address or offset goes through `build_manifest`; unresolved → + feature disabled, game untouched. +- Every item is INI-gated and A/B-toggleable at runtime. +- New hooks must coexist with EEex's detours (notably + `CVidTile::RenderTexture` and `CInfinity::Render` screen-shake hooks). +- Validation is CI build + in-game check under Proton; host tests cover the + parsing and packing logic. diff --git a/docs/superpowers/specs/2026-10-07-soft-fog-and-bloom-design.md b/docs/superpowers/specs/2026-10-07-soft-fog-and-bloom-design.md new file mode 100644 index 0000000..11b7895 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-soft-fog-and-bloom-design.md @@ -0,0 +1,444 @@ +# Soft Fog of War and Bloom — Design + +Date: 2026-10-07. Roadmap items D1 and D2 of +`2026-10-03-graphics-uplift-roadmap-design.md`. Supersedes the fog and bloom +phases (4 and 5) of `2026-06-10-graphics-enhancement-roadmap-design.md`, whose +bracket design rests on a premise this document disproves (§2.1). + +## 1. Goal + +Two world-view effects with a visible result on every map: + +- **Soft fog of war.** The engine's fog edge is built from 32 px linear + gradient pieces and reads as diamonds and stair-steps. Replace it with a + smooth edge of the same extent and darkness. +- **Bloom.** Bright parts of the world (fires, spell effects, lit windows, + lava) get a soft glow. + +Owner decisions (2026-10-07): + +- Bloom is threshold-based now, tunable. A light-source contribution is added + later, with effect-cast light (roadmap C3), not in this work. +- Animation in-betweening (old phase 1) is paused; this work comes first. + +Constraints carried over: INI-gated and off by default, UI untouched, 2.7.3 +only, every address through `build_manifest`, any failure leaves the game +drawing exactly as vanilla. + +Done means: the owner confirms both effects in game under Proton, CI is green, +and with both settings off the frame is the engine's own. + +## 2. Evidence (BGEE 2.7.3, decompiled 2026-10-07) + +### 2.1 The engine queues the whole frame + +`DrawEnd_GL` (`0x42B220`) does not draw. It appends a command (state word, +first vertex, count) to `gl.cmds`. The GPU sees nothing until `DrawFlush_GL` +(`0x42B350`, static, no arguments) walks the queue, applies state changes +against its cached `gl.hwState`, and issues `glDrawArrays`. + +`DrawFlush_GL` is called from `DrawFlip_GL` (once per frame, before +`SDL_GL_SwapWindow`), from `DrawReadPixels_GL` and `DrawLockSurface_GL`, and +from `DrawBeginScaled_GL` / `DrawEndScaled_GL` only when `gl.pp.enabled`. + +Consequences: + +- Binding a framebuffer around `CInfinity::RenderFog` captures nothing. The + 2026-06-10 fog design ("bind an FBO, call the original, blur") cannot work + as written. +- Any GL draw of ours issued during the world pass lands **under** the + still-queued world, unless the queue is flushed first. +- A mid-frame flush is an operation the engine performs itself (screenshots, + surface locks), so calling it at a chosen point has precedent. + +### 2.2 Flush side effects + +After the draws, `DrawFlush_GL` sets `gl.hwState` to the last command's +state, clears `gl.force`, resets the vertex cursor, and rewrites `gl.v.z` and +`gl.depthLockState`. Checked against a full disassembly (2026-10-07): + +- `gl.v.z` (`0x142F73F7C`) is written only by `DrawInit_GL` and + `DrawFlush_GL`, always to the same value, and read only by `DrawVertex_GL`. + It never changes during a session. +- `gl.depthLockState` (`0x142F74048`) is written by the same two functions + and never read. +- `DrawLockSurface_GL` (`0x42CAF0`) calls `DrawFlush_GL` whenever the + engine's 1024x1024 sprite atlas fills, so flushes in the middle of the world + pass already occur in vanilla on busy scenes. + +An extra flush between the world and the fog therefore changes no state that +later draws depend on, provided our own GL state is restored (§5). + +### 2.3 Engine post-processing is off in this build + +`DrawInit_GL` reads `Graphics/Postprocessing` and then sets +`gl.pp.enabled = false` unconditionally. The engine's own offscreen target +(`gl.pp.fb`, blitted with `fpCatRom`) is therefore never used in 2.7.3, which +matches gate V5 (no engine framebuffer binds) and V3 (`fpCatRom` dead). Our +code still saves and restores whatever framebuffer is bound rather than +assuming 0. + +### 2.4 Fog draw + +`CInfinity::RenderFog` (`0x2A1B60`) is the last world step before +`CInfinity::PostRender` (weather) in `CGameArea::Render` (`0x189360`). It +pushes draw state, disables texturing, sets `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`, +and emits triangles for every visible 64 px tile through +`CVisibilityMap::BltFogOWar3d` (never to be detoured) inside one +`DrawBegin`/`DrawEnd`. The fog colour is `0x030103` after fade and +brightness, i.e. near black; only alpha varies. `RenderFog` is outside the +`DrawBeginSort`/`DrawEndSort` range, which is confined to `CInfinity::Render`. + +## 3. Approach + +One hook on `CInfinity::RenderFog`, plus the address of `DrawFlush_GL`: + +``` +detour RenderFog: + flush # world so far is now in the framebuffer + if bloom: copy world -> glow chain -> add glow to framebuffer + if fog: bind fog target, clear white + original RenderFog + flush # fog triangles land in the fog target + rebind engine framebuffer + blur fog target -> multiply framebuffer by it + else: original RenderFog + restore GL state +``` + +Weather and the UI are queued afterwards and drawn at flip, on top, as today. +Bloom runs before fog so unexplored areas stay dark. + +### 3.1 Why white-clear and multiply + +Drawing alpha-blended black over a white target leaves, per pixel, the +product of `(1 - alpha)` over every fog triangle covering it: exactly the +fraction of the scene the engine's fog would let through, correct for +overlapping triangles too. Multiplying the framebuffer by that value +reproduces the engine's result (the fog colour's own contribution, at most +3/255, is dropped). With blur radius 0 the output must match vanilla to +within that error; this is the acceptance check for the capture path. + +### 3.2 Alternatives considered + +| Alternative | Why not now | +|---|---| +| Skip the original `RenderFog`; build a small visibility texture from the tile codes and draw one smooth-upsampled pass | No second flush and full control of the falloff, but it re-implements the engine's fog rules (explored vs unexplored shades, corner codes, fade, the fog option) from `CVisibilityMap` internals and needs several more manifest entries. Worth revisiting if the blurred capture looks wrong at the edges | +| Redirect the whole world pass into our own framebuffer and blit at the end | Gives a clean world-only image but moves every engine draw off the default framebuffer; far larger blast radius for the same result | +| Do the bloom at flip time | The UI is already in the image by then and would glow | +| Capture into a transparent target | Blending onto an empty target squares alpha and compounds on overlap; not recoverable in general | + +## 4. Components + +| Unit | Responsibility | Depends on | +|---|---|---| +| `game/world_post_plan.*` | Pure sizing and parameter logic: target sizes, glow chain levels, fog blur radius in pixels from world radius and zoom, clamping of INI values. Host-tested | nothing | +| `core/gl_pass_guard.h` | RAII save/restore of every GL state our passes touch (§5) | GL function table | +| `features/world_post.*` | Owns GL resources (targets, programs, quad). `begin_world_post()` / `end_fog_capture()` style entry points called by the hook. Lazy creation, resize on viewport change, drop on context change, one-shot failure latch | plan, guard, GL table | +| `hooks.cpp` | `detour_render_fog`, install/uninstall, gating | manifest, config | +| `game/build_manifest.*` | Patterns and reference RVAs for `CInfinity::RenderFog` and `DrawFlush_GL`, each verified unique offline | — | +| `game/opengl_types.*` | Added entries: framebuffer objects, `glBlitFramebuffer`, vertex array and buffer objects, and the fixed-function calls the guard needs | — | +| `core/config.*` | New INI keys (§6) | — | + +Shaders are short GLSL sources embedded in `world_post.cpp` (our own +programs, not engine overrides): copy/downsample, bright-pass, blur +up/down, composite. + +### 4.1 Bloom pass + +1. `glBlitFramebuffer` the framebuffer to a half-resolution RGBA16F target + (linear filter). +2. Bright-pass with a soft knee around `BloomThreshold`. +3. Dual-filter blur: downsample through a fixed number of levels (count from + the plan, capped by target size), then upsample back, accumulating. +4. Add the result to the framebuffer (`ONE, ONE`) scaled by `BloomStrength`. + +At the hook point the framebuffer holds only world content for this frame, so +no world rectangle is needed and the UI cannot glow. + +### 4.2 Fog pass + +1. Full-size RGBA8 fog target, cleared white; original `RenderFog`; flush. +2. Downsample to quarter size, blur with the same dual-filter code; radius + from `SoftFogRadius` (world pixels) times the current zoom, taken from the + view transform the mod already publishes (`area_state.h`). +3. Multiply the framebuffer by the blurred result (`DST_COLOR, ZERO`), with a + small ordered dither to hide 8-bit banding in the long dark gradient. + +Radius 0 skips step 2 and composites the capture directly. + +## 5. GL state contract + +The engine believes the hardware state equals `gl.hwState` after a flush and +only issues changes relative to it. Everything our passes change must be put +back exactly. The rule is "restore everything our passes change", which is: + +- `GlPassGuard`: current program, vertex array and array buffer bindings, + viewport, blend enable and functions, scissor, depth, stencil, alpha-test + and cull-face enables, clear colour. +- `GlStateGuard({0})`: active texture and the 2D binding on unit 0. Our + passes only ever bind textures on unit 0. +- The feature module itself: the draw and read framebuffer bindings, read at + the hook and rebound before returning. + +Not touched, so not saved: depth and colour masks (depth test is off, so +nothing writes depth), client-active texture, unit 1, texture environment. + +All of this runs on the render thread inside an engine render call, the same +thread and context as the existing draw-time hooks. + +## 6. Configuration + +`[Rendering]`, all off or neutral by default: + +| Key | Default | Meaning | +|---|---|---| +| `SoftFogOfWar` | `false` | Enable the fog capture and soft composite | +| `SoftFogRadius` | `24` | Blur radius in world pixels; `0` = capture without blur (must match vanilla) | +| `Bloom` | `false` | Enable bloom | +| `BloomThreshold` | `0.80` | Brightness above which pixels glow (0..1) | +| `BloomStrength` | `0.35` | Glow intensity | + +With `EnableDebugHotkeys` on, one key per effect toggles it at runtime for +A/B comparison, using the existing hotkey mechanism. + +## 7. Failure handling and logging + +- Missing or non-unique pattern for either address: both features off, one + log line naming which. +- Missing GL entry point, incomplete framebuffer, shader compile or link + error: log the reason once, latch the feature off for the session, call the + original `RenderFog` untouched. +- Context change: drop all GL objects and recreate on next use. +- Field log, once per area load: which effects engaged, target sizes, zoom, + the framebuffer found bound at the hook, and one line the first time each + pass draws. Verbose logging adds per-frame counts. + +## 8. Build order and validation + +Each step is a CI bundle the owner can check in game. + +1. **Flush and capture, no blur.** Hook, manifest entries, guard, fog capture + with `SoftFogRadius = 0`. Check: looks identical to `SoftFogOfWar = false`, + including sprite/wall occlusion, weather, UI and screenshots. This + validates the capture path and the state restore of §5. +2. **Soft fog.** Blur and dither. Check: edge smoothness at several zoom + levels, no light leak at explored boundaries, radius tuning by eye. +3. **Bloom.** Check: fires and spells glow, UI does not, bright daytime maps + are not hazy at defaults; tune threshold and strength by eye. + +Host tests cover `world_post_plan` (sizes, level counts, radius scaling, +clamping) and INI parsing. GL behaviour is validated in game only. + +## 9. Known limits and risks + +- **GL state restore** (§5): a missed piece of state corrupts the engine's next draws; step 1 of §8 is the check. +- Selection circles, health bars and other bright world-space markers are + part of the world image and will glow slightly. The later light-source + path is the fix. +- Weather (rain, snow, lightning flash) is drawn after the hook and does not + glow. +- The soft edge reveals a few pixels more at the visible boundary and darkens + a few pixels inside it; the radius controls both. +- Threshold bloom on painted maps can haze bright scenes; defaults are + conservative and the owner tunes by eye. +- 2.7.3 only. Other builds leave both features off. + +## 10. Out of scope + +Light-source bloom, effect-cast light, normal-derived relief, FXAA, water +reflections, changes to the engine's fog rules or colours. + +## 11. Revision 2026-10-07: bloom from the engine's additive draws + +In-game result of the threshold bloom (owner): bright painted art such as +paper glowed as much as flames. The threshold approach is removed. + +`CVidCell::RenderTexture` (`0x425530`) selects the blend mode from the sprite +render flags: flag `0x8` or `0x200` gives a destination factor of `GL_ONE`, +everything else is normal alpha blending. The engine's own art flags therefore +mark what emits light. Every queued command keeps its blend factors in its +state word (`DrawBlendFunc_GL`: source at bits 9-12, destination at 13-16; +factor table at `0x5C12B0`, index 1 = `GL_ONE`). + +Design: + +- `DrawFlush_GL` is detoured. During the world pass (frame boundary to + `RenderFog`) the detour copies the queue's additive commands, calls the + original, then binds a black "emissive" target, writes those commands back + into the queue and calls the original again. Vertex data and atlas textures + from the first flush are still valid: the engine overwrites them only when + new draws are queued. +- `gl.n` is located from the rip-relative compare at `DrawFlush_GL+0x24`; + `gl.cmds` lies `0x180A8` bytes before it (`BuildManifest::drawQueue`). +- Before the fog, the emissive image is halved, blurred through the bloom + chain, and applied twice: `framebuffer += framebuffer * light * LightSpill` + (surfaces near a source brighten and take its colour) and + `framebuffer += light * BloomStrength` (the halo). +- INI: `BloomThreshold` is gone; `LightSpill` (default 1.5, 0 = off, max 8). + +Known limits: the replay has no depth buffer, so a light source hidden behind +a wall still glows; the light spill is screen-space, with no falloff model and +no wall occlusion; art drawn with flag `0x200` (`DST_COLOR, ONE`) contributes +nothing to a black target. + +## 12. Revision 2026-10-07 (later): fog drift and light falloff + +Owner feedback on section 11's build: bloom and fog both "feel better"; asked +for more. + +- **Fog drift.** The blurred fog is composited by its own program, which + offsets the lookup by slow two-octave value noise anchored to world + coordinates (`SoftFogDrift`, world pixels, default 10, 0 = still). Flat + regions are unaffected, so only the edge moves. Needs the view transform; + without it the edge stays still. +- **Light falloff.** The light spill now samples a deeper level of the bloom + chain (wide blurs only) and maps it through `1 - exp(-x)`, so a small source + lights a wider area and the area next to it saturates instead of clipping. + +## 13. Revision 2026-10-07 (later): fog smoothed over time + +Owner observation: the fog is "pinned to one tick". The engine recomputes +visibility on its logic tick, so the reveal steps while sprites and the camera +move at display rate. + +The blurred fog is now blended with the image shown on the previous frame +(`SoftFogSmoothing`, seconds, default 0.25, 0 = off): each frame the shown fog +moves `1 - exp(-dt / smoothing)` of the way to the new capture +(`game::temporal_blend`). The previous image is sampled at the same world +position, using the current and previous view transforms, so scrolling and +zooming do not smear it; positions that were off screen take the new value. +History is dropped on area load, on resize, after a gap of more than 0.5 s, +and whenever the view transform is unknown. The unblurred path +(`SoftFogRadius = 0`) is not smoothed and still reproduces the engine's fog. + +This relies on `area::read_view_transform` being correct at `RenderFog` time, +which is not yet confirmed in game: a wrong scroll would show as fog trailing +behind the camera. + +## 14. Revision 2026-10-07 (later): heat shimmer + +Owner verdict on section 13: fog smoothing is "perfect". Next pick: heat haze. +Rejected by the owner: smoothing day/night transitions (areas swap maps, so +there is nothing to blend) and radius-based light sources with wall blocking +(not convinced it would work). + +`HeatShimmer` (world pixels, default 2.5, 0 = off, needs `Bloom`): after the +emissive blur and before the glow is added, the world image is copied and +redrawn with its lookup rippled by world-anchored, upward-flowing noise. The +ripple is scaled by how warm the blurred emissive image is (red minus blue), +read at the pixel and at two points below it so the haze sits above its +source. Cold and white effects do not shimmer. + +All shaders are checked offline with `glslangValidator` (extract the +`R"glsl(...)glsl"` bodies from `world_post.cpp`; append the shared noise +source to those that declare `float noise(vec2 p);`). A shader that fails to +compile in game switches fog and bloom off for the session, so run this +before pushing shader changes. + +## 15. Revision 2026-10-07 (later): what else is stepped on the logic tick + +Owner request: find everything pinned to the engine tick and decouple it. +Survey of the world-pass draws against their per-tick updates (2.7.3): + +| Thing | Per-tick update | Status | +|---|---|---| +| Creatures, circles, health bars, 8 projectile/effect types | `m_pos` | Smoothed earlier (item A) | +| Fog of war | visibility map | Smoothed (section 13) | +| Rain, snow, sparkles | `CParticle::AsynchronousUpdate` (`0x423C30`) adds velocity to position | **Smoothed now** | +| Floating text over creatures | `CGameText::AIUpdate` copies the target's `m_pos` | **Smoothed now** | +| Selection-circle pulse, talk markers | `CMarker::AsynchronousUpdate` counters | Not done: a size animation, not a position; low value | +| `CProjectileSkyStrikeBAM`, spell-hit and travel-door projectiles | not drawn from `m_pos` | Not done | +| Animation frames of effects and ambient animations | frame counters | Needs in-between art (paused item F) | +| Screen fades, day/night tint | stepped values | Not done; day/night rejected by the owner | +| Camera scroll | — | Already at display rate under EEex | + +Particles: `CParticle::Render` (`0x425BE0`; called by `CVidMode::BKRender` +for rain and snow and by `CSparkleCluster::Render`) is wrapped. A `TickClock` +fed from the particle update gives the fraction of the current tick elapsed; +the particle is drawn at `position - velocity * (1 - fraction)`, which is +exact because the update is a plain add. Gravity-only particles step back by +their gravity in height. The position is restored after the engine call +unless the engine changed it meanwhile. Layout mirrored as `game::CParticle`. + +Floating text: `CGameText::Render` (`0x1F39A0`) joins the smoothed-object +table, so it slides with its creature. + +Both ride on `SmoothSpriteMovement`. Known gap: the tick clock is global, so +if some particles keep updating while others are frozen (time stop during +rain), the frozen ones jitter by one step. + +## 16. Revision 2026-10-08: sprite smoothing with FSR1 + +Owner's goal after rejecting every detail-adding upscaler (offline comparison +sheets in the workspace's `interp-preview/`): sprites "don't need to be that +much better, just smoother". Chosen filter: FSR1. + +Evidence (2.7.3): + +- Sprites are composited on the CPU into a streaming atlas (`fx[0]` nearest, + `fx[1]` linear; 1024x1024). `DrawFlush_GL` begins by uploading each atlas's + used rows through `TexSubImage` -> `TexSubImage_GL` (`0x42D190`), then draws + the queue. `TexSubImage_GL` binds `gl.textures[selected].name` and marks the + texture for rebinding. +- `gl.textures` (`0x757040`, 0x28-byte entries, GL name first), `gl.user.state` + (`0x2F73F6C`, selected texture in bits 21-29) and `fx[]` (`0x2F74050`, 0x30 + apart) sit at fixed distances from `gl.n` (`BuildManifest::spriteAtlas`). +- `CVidCell::Blt8To32` writes only non-transparent pixels; the atlas buffer is + cleared to zero, so transparent texels are `(0,0,0,0)`. + +Design: + +- `TexSubImage_GL` is detoured. After the engine's upload of an atlas, during + the world pass, the atlas is upscaled 2x into our own texture with EASU + (12-tap edge-adaptive kernel; alpha is filtered with colour and counts + towards edge detection) and sharpened with RCAS. The texture's name in the + engine's table is replaced by ours until the flush ends; the flush detour + restores it. Texture coordinates are normalised by the table's stored size, + so they address the larger texture unchanged. +- The world pass is now "open" only on frames following a frame that drew an + area, so menus and full-screen panels are never touched. +- INI: `SpriteUpscale` (default false), `SpriteSharpness` (0..1, default 0.5); + F7 toggles with debug hotkeys on. +- The shaders' maths was checked offline with a NumPy transcription + (`interp-prototype/sr/easu_check.py`) against the reference FSR1 port. + +Known limits: 2x only (the remaining magnification is the engine's bilinear); +neighbouring sprites on the atlas can bleed up to two texels into each other's +edges; the dark fringe from blending non-premultiplied alpha is unchanged +(fixing it needs the blend mode swapped for alpha-blended sprites only, since +additive sprites share the atlas); BAM v2 (PVRZ) sprites bypass the atlas and +are not filtered. + +## 17. Revision 2026-10-08 (later): other sprite filters tried and removed + +After FSR1 2x first ran in game the owner disliked it and asked for +Catmull-Rom followed by EASU. A build with four filters cycled by F7 (EASU 2x, +Catmull-Rom 2x, Catmull-Rom then EASU at 4x, EASU twice at 4x) let them compare +in game. Verdict: FSR1 2x (EASU then RCAS) is the one they prefer; the other +three are "pure shite". That build was reverted, so the sprite pass is section +16's again: one EASU stage and RCAS, toggled with F7. + +Lesson for this owner: offline comparison sheets did not predict their in-game +opinion in either direction. Give them a runtime toggle and let them judge. + +## 18. Revision 2026-10-08 (later): selection circles vanishing with sprite smoothing + +Owner report: with `SpriteUpscale` on, selection circles sometimes disappear. + +Cause (2.7.3, decompiled): the engine has no untextured draw. +`DrawDisable_GL(DRAW_TEXTURE_2D)` (`0x42B050`) selects atlas 0's texture +(`fx[0]`) and sets the texture coordinate to the atlas's size, i.e. (1, 1). +`DrawInit_GL` uploads a 2x2 white block once at `(texW-2, texH-2)` of that +atlas, so selection circles (`CVidMode::DrawEllipseArc`), health bars and every +other plain-coloured shape are white texels times the vertex colour. The 2x +copy only redrew the rows the engine had just uploaded, so its last corner was +never filled and those shapes came out transparent in every flush that swapped +atlas 0. + +Fix: after the uploaded rows, the same two passes redraw the corner of atlas +0's copy (`game::untextured_corner`). The log now names each atlas slot the +first time it is upscaled. + +Rule for any later change to the atlas swap: whatever replaces atlas 0 must +keep its last corner white. Owner confirmed the fix in game the same day. diff --git a/docs/superpowers/specs/2026-10-08-effect-replacement-blood-design.md b/docs/superpowers/specs/2026-10-08-effect-replacement-blood-design.md new file mode 100644 index 0000000..fcccfd8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-effect-replacement-blood-design.md @@ -0,0 +1,255 @@ +# Shader effect replacement, with blood as the first effect + +Date: 2026-10-08 +Status: Design, awaiting owner review. Nothing here is built. +Target: BGEE 2.7.3 (x64, Proton), EEex-loaded DLL. Other builds leave it off. + +Roadmap item C2 of +[2026-10-03-graphics-uplift-roadmap-design.md](2026-10-03-graphics-uplift-roadmap-design.md). +The standing constraints of that document apply (manifest-only addresses, +fail closed, INI gate plus runtime toggle, coexist with EEex's detours). + +## 1. Goal + +A mechanism that lets a GLSL shader repaint a chosen engine effect, and one +effect that proves it: the blood splash on a hit. + +Owner intent (2026-10-08): the remaining effect work is "replace effects with +GLSL"; start with blood, then add effects one at a time, most common first. +Anything not replaced must keep drawing exactly as today. + +Success for this spec: + +- Hit blood is drawn as smooth, glossy liquid at screen resolution instead of + magnified sprite pixels, in the creature's own blood colour, darkened by the + area light like everything else, hidden behind walls like today. +- A debug hotkey switches between the engine's blood and ours while playing. +- Adding the next effect needs a table entry and a shader, not new plumbing. +- The log lists which effects were replaced and which other effect art was + seen at the same hook, so the next effects are chosen from play, not guessed. + +## 2. Evidence (2.7.3, decompiled 2026-10-08) + +### 2.1 "Blood" is two systems + +`CGameSprite::AddBlood` (`0x347BC0`) branches on the blood type: + +| Kind | Path | Drawn by | +|---|---|---| +| Hit blood (`GUSH_LOW/MEDIUM/HIGH`, `PULSATING_ARTERY`) | `StartSpriteEffect(0, type, 0)` (`0x3794D0`) loads `BLOODS`, `BLOODM`, `BLOODL` or `BLOODCR` into the creature's `m_spriteSplashVidCell` (`CGameSprite + 0x3DE0`) and recolours its palette with the creature's blood colour (`GetColorBlood` / `m_bloodColor`) | `CGameSprite::RenderSpriteEffect` (`0x370E20`), called from `CGameSprite::Render` | +| Exploding death (`EXPLODING_DEATH`) | A `CBlood` object (`0x220F00`) holding `CParticle` dots and blobs, kept in `m_lstBlood` | `CBlood::Render` (`0x224F20`) through `CParticle::Render`, before or after the body depending on direction | + +Hit blood is the common case and the trial. The particle system is a later +second user of the same shader (section 9). + +### 2.2 The hit-blood draw + +`RenderSpriteEffect` draws the splash cell while its sequence has not ended: +`FXPrep` / `FXLock` / `FXRender` composite the frame on the CPU into the sprite +atlas, `FXRenderClippingPolys` cuts it against the wall polygons, and +`FXUnlock` / `FXBltFrom` queue one quad. The position is the creature's +`m_pos` plus its height offset; `MIRROR_FX` flips it for mirrored animations. + +The same function and the same cell also draw the fire, shock and acid impact +art (`SPFIRIMP`, `SPSHKIMP`, `SPBURN`, `SPSPARKS`, or a VVC named by the +effect). Whether a call is blood is decided by the splash cell's resref, never +by the call. + +The four BAMs are small and short: 6 to 13 frames, 5 to about 50 pixels on a +side (`BLOODS` 8 frames up to 17x16, `BLOODM` 13 up to 23x43, `BLOODL` 13 up +to 49x52, `BLOODCR` 6 up to 47x45). At the owner's zoom (about 7.5 screen +pixels per world pixel) one splash covers up to roughly 370 screen pixels. + +### 2.3 The draw queue merges draws + +`DrawEnd_GL` (`0x42B220`) appends a command only when the state word differs +from the previous command's; otherwise it adds the new vertices to the +previous command's `primCount`. One command can therefore hold several +sprites. A draw is identified by its vertex range, not by a command index: +`gl.user.primStart` (`0x2F73F70`, `0x60` bytes before `gl.n`) is the vertex +cursor, and it advances by exactly the vertices a draw adds. + +`DrawFlush_GL` resets the cursor to 0. The mod already calls the engine's +flush more than once per frame with a rewritten queue (the bloom's emissive +replay), and vertex data and atlas textures stay valid across those calls +until new draws are queued. + +## 3. Approach: the engine's own draw becomes the guide + +The engine keeps drawing the effect. The mod records which vertices that draw +added, and when the queue is flushed it sends just those vertices to an +offscreen "guide" image instead of the screen, then runs the effect's shader +over that part of the screen with the guide as input. + +Per replaced draw, inside one flush: + +1. Flush everything queued before it to the screen (the engine's flush, on a + shortened queue). +2. Clear the guide image in the effect's screen rectangle, bind it, and let + the engine's flush draw only the effect's vertices into it. +3. Bind the screen again and draw the effect shader over that rectangle, + reading the guide. +4. Continue with the rest of the queue. + +Why this shape: + +- **Everything the engine decides stays decided by the engine**: position, + mirroring, the creature's blood colour, the area light tint, wall clipping, + draw order against other creatures, the gore option, and movement smoothing + (the hook runs inside the already-smoothed creature render). None of it has + to be re-derived or kept in step. +- **Logic is untouched.** The engine's draw still runs, so frame counters, + durations and the cells `InterpolateAnimations` counts for a creature are + exactly as today. +- **Off means off.** With the feature disabled or failed, no range is + recorded and the flush is the engine's own. + +What it costs: the shader can only paint where the engine's art is, plus a +small margin. It gives a better-looking version of the same splash, not a +different, larger or longer one. + +### 3.1 Alternatives considered + +| Alternative | Why not now | +|---|---| +| **Free-form draw**: skip the engine's draw, flush, and draw our own quad with our own simulation (droplets with their own motion, any size, any lifetime) | Flashier, but it loses wall clipping (blood shows through walls), and position, mirroring, colour, light tint and the gore option all have to be rebuilt by hand. Skipping the engine's draw also has to be proven not to change effect lifetime. Worth revisiting per effect if the guided result is too tame; the table (section 4.2) leaves room for it | +| **Paint in the tile shader**, as fire and smoke do today | Sits under every creature, is multiplied by the tile light, and costs a per-pixel loop over all points. Those limits are why item C wanted to leave `fpSEAM` | +| **Batch all effects before the fog** | One pass instead of one per effect, but every effect is drawn over creatures standing in front of it | +| **Replace the BAM art** with better pre-rendered frames | Still sprite pixels magnified 7x; the owner rejected every sprite upscaler | + +## 4. Components + +New code lives in its own files; `world_post.cpp` is already 1,100 lines. + +### 4.1 `game/effect_ranges.*` (pure, unit-tested) + +Tracks vertex ranges and plans a flush. + +- `EffectRangeRecorder`: `begin(effect, cursor)` / `end(cursor)` around a + hooked draw; holds up to 16 ranges per flush. `on_flush()` returns the ranges + and clears them. A range still open at a flush (the engine flushes mid-frame + when its atlas fills) is closed at the current cursor and reopened at 0. + An empty range (the engine drew nothing) is dropped. +- `plan_flush(commands, ranges)`: turns the queue into an ordered list of + segments, each a run of commands tagged "screen" or "effect N". A command + whose vertices straddle a range boundary is split into copies with adjusted + `primStart` / `primCount`. Clear commands (`primCount == -1`) stay in screen + segments. Anything implausible (overlapping ranges, counts out of bounds) + yields a single screen segment: the engine's own frame. + +### 4.2 `game/effect_table.*` (pure, unit-tested) + +Maps an effect resref to how it is replaced: a shader kind and its +parameters. Entries for this spec: `BLOODS`, `BLOODM`, `BLOODL`, `BLOODCR` to +the `Liquid` kind. The table is compiled in; no file format until a second +effect kind shows what one would need. + +### 4.3 `features/world_effects.*` (GL) + +Owns the guide image (screen-sized RGBA8, recreated on resize) and the effect +programs. Three calls used by the flush detour: clear the guide in a +rectangle and bind it; bind the screen back; draw an effect over a rectangle. +Uses `GlPassGuard` / `GlStateGuard` as the world post passes do, and restores +the framebuffer bindings it found. + +The small GL helpers both features need (`Target`, `create_target`, +`build_program`, the full-screen vertex array) move from `world_post.cpp` into +a shared `features/gl_pass_util.*`. No behaviour change; nothing else in +`world_post.cpp` is restructured. + +### 4.4 Hook and flush changes (`hooks.cpp`, `build_manifest`) + +- New manifest pattern for `CGameSprite::RenderSpriteEffect`, with a + `static_assert` pinning it to the function's first bytes and a log line for + the resolved address (the 2026-10-08 lesson). New layout value: the vertex + cursor's distance from `gl.n`. +- The detour reads the splash cell's resref. If the world pass is open, the + feature is on and the table has the resref, it records the cursor, calls the + engine, and records the cursor again. Otherwise it only calls the engine. + The effect's screen rectangle comes from the creature position and the + cell's current frame box, padded by the shader's reach. +- `detour_draw_flush` takes the recorded ranges. With none, it behaves as + today. With some, it runs the segments of `plan_flush` through the engine's + flush one after another (the first carries the atlas upload), switching + target around effect segments, then does the emissive replay and the atlas + restore as today. + +## 5. The liquid shader + +Input: the guide image, which holds the engine's splash as premultiplied +colour (drawn with `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` into a cleared target, so +its alpha is the sprite's alpha squared; the shader takes the square root). + +1. **Shape.** Sample the guide's alpha around the pixel with a small kernel + whose radius is a fraction of a world pixel times the zoom, and threshold + the result with a soft edge. Blurring then thresholding rounds the + magnified pixel blocks into smooth blobs and joins neighbouring droplets. +2. **Colour.** The blurred colour divided by the blurred alpha: the + creature's blood colour with the engine's light tint already in it. +3. **Depth and gloss.** The gradient of the blurred alpha stands in for a + surface normal: a darker rim, a slightly lighter centre and a small + highlight, so it reads as wet rather than flat. +4. **Output.** Normal alpha blending onto the screen. Blood is not additive + and does not feed the bloom. + +Shaders are validated offline with `glslangValidator`, like the others. + +## 6. Configuration + +```ini +[Effects] +; Draw hit blood as smooth, glossy liquid instead of sprite pixels. +ShaderBlood = false +; How wet it looks: 0 = flat colour, 1 = strong rim and highlight. +BloodGloss = 0.6 +``` + +Default off in the sample; on, with a visibly strong gloss, in the owner's +INI. With `EnableDebugHotkeys`, one key toggles it; F7 to F10 are taken, so +the key is chosen after checking the game's own key bindings. + +## 7. Failure handling and logging + +- Any GL error creating the guide or a program, or an invalid manifest: + the feature turns itself off with one log line and the engine draws as + before. +- More than 16 replaced draws in one flush: the extra ones are drawn by the + engine as today. +- Logged once per resref: "replaced" or the reason it was not. Logged once + per other resref seen in the splash cell: this is the list of impact art + the hook could replace next. +- The per-area `World post:` line gains the number of replaced draws and + the number of extra flushes that frame, so the cost is visible. + +## 8. Testing and validation + +- Unit tests: the range recorder (nesting refused, flush while open, empty + ranges, the cap) and `plan_flush` (no ranges, a range inside one merged + command, a range across several commands, a range at either end, clear + commands, implausible input); the table lookup. +- Host tests and the mingw compile check locally, then CI. +- In game, by the owner: hit something, toggle the key, compare. Checks worth + making: blood colour on a non-red creature, a hit behind a wall, a hit on a + mirrored creature, a crowded fight (cost), and that fire and shock impacts + look unchanged. + +## 9. Known limits + +- The splash keeps the original's shape, size, duration and frame steps. This + is a better-looking version of the same effect. +- Each replaced draw adds two calls to the engine's flush and two small + passes. Fine for a handful per frame; the log shows the count. +- Overlapping semi-transparent pixels within one splash accumulate in the + guide differently from on screen; with single-layer splash frames this does + not arise. +- 2.7.3 only. + +## 10. Out of scope, in likely order + +1. Exploding-death blood: bracket `CBlood::Render` the same way so its dots + and blobs feed the same liquid shader and merge into streams. +2. The other impacts drawn by the same hook (fire, shock, acid). +3. Spell effects: `CVEFVidCell::Render` is the next hook site. +4. Blood that stays on the ground for a while. That is a new effect, not a + replacement, and needs its own place in the draw order. +5. A free-form effect kind (section 3.1), if a specific effect needs it. diff --git a/docs/tile-upscale.md b/docs/tile-upscale.md index d806bd3..9302ab0 100644 --- a/docs/tile-upscale.md +++ b/docs/tile-upscale.md @@ -17,12 +17,15 @@ engine's 64x64 screen-space quad and leaving ARE/WED coordinates untouched. 4. If the header is missing, inspect up to 32 entries from the 12-byte PVR table. Validate page/coordinate bounds, then take the GCD of non-zero `u`/`v` deltas between entries on the same atlas page. -5. Only if both deterministic paths fail, fall back to the legacy UV / texture-id heuristic path and log that fallback. +5. If both deterministic paths fail, detection returns nothing and the render + path samples up to 10 draws before delegating the tileset to the engine as + standard 1x. The header is authoritative when present. Standard tilesets can legitimately have `header == null`, so table-based detection is also an expected deterministic path. Raw `u`/`v` origins are never tile -dimensions; only their translation-invariant grid deltas are considered. Heuristics are a final -safety net. +dimensions; only their translation-invariant grid deltas are considered. The former UV / texture-id +heuristic tier was removed after producing false 4x detections on vanilla areas in-game (raw UV +magnitude is atlas-origin noise and GL texture ids grow with session allocations). The value at `+0x14` is TIS metadata. The PVRZ header describes the atlas page and does not replace this logical tile-size field. Header and table inference @@ -80,5 +83,6 @@ The current build still uses the TIS `+0x1DC` linear-tiles switch for the seam/l ## Failure Model - Missing manifest or incompatible callsites abort initialization. -- Missing tile metadata falls back to heuristics. +- Missing tile metadata fails closed: after the sampling window the tileset is + delegated to the engine as standard 1x. - Unknown builds should stop during initialization rather than silently running with invalid hooks. diff --git a/docs/validation/bgee-2.7.3-evidence.md b/docs/validation/bgee-2.7.3-evidence.md index b373622..48cef2d 100644 --- a/docs/validation/bgee-2.7.3-evidence.md +++ b/docs/validation/bgee-2.7.3-evidence.md @@ -17,6 +17,15 @@ Runbook: [new-build-validation.md](../new-build-validation.md) |---|---|---|---|---|---| | `CInfGame::LoadArea` | unchanged 2.6.6 pattern | exactly 1 | `0x27EBD0` | `0x27E710` | `+0x4C0` | | `CVidTile::RenderTexture` | unchanged 2.6.6 pattern | exactly 1 | `0x4257C0` | `0x4247E0` | `+0xFE0` | +| `CGameObjectArray::GetShare` | `48 C7 02 00 00 00 00 83 F9 FF` | exactly 1 | `0x276700` | `0x276490` | `+0x270` | + +GetShare's body is byte-identical in shape on both builds (RIP operands at ++0x1B/+0x35): 2.7.3 globals decode to `m_maxArrayIndex = 0x68F8F4` and the +entry table at `0x68F910` (2.6.6: `0x68D434`/`0x68D450`). Both binaries hold +exactly one `operator new(0x368)` + `imul ..., 0x4C` CGameStatic allocation +site, so the static object size and ARE record stride are unchanged +(2026-07-17, ARE-animation memory path; see +[are-animation-detection.md](../are-animation-detection.md)). Both prologues are byte-identical to 2.6.6 (24-byte dumps recorded in the validation session log). diff --git a/src/iee/app_context.h b/src/iee/app_context.h index 4d8496d..bab870d 100644 --- a/src/iee/app_context.h +++ b/src/iee/app_context.h @@ -4,6 +4,7 @@ #include #include "iee/core/config.h" +#include "iee/game/are_animations.h" #include "iee/game/build_manifest.h" #include "iee/game/game_addrs.h" #include "iee/game/renderer.h" @@ -24,11 +25,16 @@ struct AppContext { // the active area when transitions settle after LoadArea returns. std::atomic infGame{nullptr}; std::atomic> wed{}; + // Authored ARE ambient animations of the active area, classified for + // point-effect placement. Detection data only; no render path consumes + // it yet. + std::atomic> areaAnimations{}; game::ResrefBuffer lastLoggedWedArea{}; void reset_area_state() { activeArea.store(nullptr); wed.store(std::shared_ptr{}); + areaAnimations.store(std::shared_ptr{}); lastLoggedWedArea.fill('\0'); } diff --git a/src/iee/area_state.cpp b/src/iee/area_state.cpp index 100282b..90df4bb 100644 --- a/src/iee/area_state.cpp +++ b/src/iee/area_state.cpp @@ -2,18 +2,24 @@ #include #include +#include #include #include #include #include +#include #include +#include #include +#include #include "app_context.h" #include "iee/core/gl_state_guard.h" #include "iee/core/logger.h" #include "iee/core/pattern_scanner.h" +#include "iee/game/are_animations.h" #include "iee/game/area_texture.h" +#include "iee/game/object_statics.h" #include "iee/game/opengl_types.h" #include "iee/game/resref_runtime.h" #include "iee/game/texture_units.h" @@ -40,6 +46,17 @@ std::atomic g_nextAreaGpuGeneration{1}; std::mutex g_areaRefreshCommitMutex; std::atomic g_areaRefreshGeneration{0}; +// Authored BAM draw boxes of the current area's replaced statics, keyed by +// the live CGameStatic. Captured on the render thread once the engine has +// loaded each BAM; kept because the engine may release the resource again +// while we suppress its draws. Cleared at every LoadArea (object addresses +// are reused across areas). +std::mutex g_staticEnvelopeMutex; +std::unordered_map g_staticEnvelopes; +std::atomic g_staticEnvelopesDirty{false}; +constexpr std::size_t kMaxStaticEnvelopes = game::kMaxAreaAnimationRecords; +constexpr auto kEnvelopeRepublishInterval = std::chrono::milliseconds(100); + // Render-thread-owned GL state. unsigned g_areaTexture{}; HGLRC g_areaTextureContext{}; @@ -64,6 +81,194 @@ void queue_no_liquid_snapshot() { queue_area_gpu_snapshot(std::move(noLiquid), {0.5f, 0.5f, 0.5f}); } +constexpr std::size_t kMaxUnclassifiedResrefsLogged = 12; + +// Per-area log dedupe across the LoadArea and render-thread refresh paths. +std::mutex g_areAnimationsLogMutex; +game::ResrefBuffer g_lastLoggedAreResref{}; + +bool read_area_resref(const game::CGameArea* area, game::ResrefBuffer& out) { + game::CResRef runtimeResref{}; + const auto* resrefAddress = + reinterpret_cast(area) + offsetof(game::CGameArea, m_resref); + return core::safe_read(resrefAddress, runtimeResref) && + game::read_runtime_resref(runtimeResref.m_resRef.data(), out); +} + +void log_area_animation_summary(const game::AreaAnimationsInfo& info) { + std::size_t shown = 0; + for (const auto& animation : info.animations) { + if (animation.isShown()) ++shown; + } + LOG_INFO( + "ARE animations {}: total={}, shown={}, fire={}, smoke={}, fountain={}, light={}, " + "water={}, lava={}, wildlife={}, unclassified={}", + info.areaResrefView(), info.animations.size(), shown, + info.count_of(game::AreaAnimationKind::Fire), info.count_of(game::AreaAnimationKind::Smoke), + info.count_of(game::AreaAnimationKind::Fountain), + info.count_of(game::AreaAnimationKind::Light), info.count_of(game::AreaAnimationKind::Water), + info.count_of(game::AreaAnimationKind::Lava), + info.count_of(game::AreaAnimationKind::Wildlife), + info.count_of(game::AreaAnimationKind::None)); + + std::string unclassified; + std::size_t unclassifiedListed = 0; + for (const auto& animation : info.animations) { + if (animation.kind != game::AreaAnimationKind::None) { + LOG_DEBUG("ARE animation {}: kind={}, resref={}, name=\"{}\", pos=({}, {}), objPos=({}, " + "{}, z={}), box=({},{} .. {},{} valid={}), shown={}, lightSource={}", + info.areaResrefView(), game::area_animation_kind_name(animation.kind), + animation.resrefView(), animation.nameView(), animation.x, animation.y, + animation.objX, animation.objY, animation.objZ, animation.envelope.left, + animation.envelope.top, animation.envelope.right, animation.envelope.bottom, + animation.envelope.valid, animation.isShown(), animation.isLightSource()); + continue; + } + if (unclassifiedListed < kMaxUnclassifiedResrefsLogged && !animation.resrefView().empty() && + unclassified.find(animation.resrefView()) == std::string::npos) { + if (!unclassified.empty()) unclassified += ", "; + unclassified += animation.resrefView(); + ++unclassifiedListed; + } + } + if (!unclassified.empty()) { + // The visibility that grows the classification table from real data. + LOG_INFO("ARE unclassified animation resrefs for {}: {}", info.areaResrefView(), unclassified); + } +} + +// Resolves the CGameObjectArray globals once per process from the manifest's +// GetShare pattern. Any ambiguity fails closed and latches, disabling the +// scan for the session. +const game::ObjectArrayGlobals& resolved_object_array(const game::BuildManifest& manifest) { + static const game::ObjectArrayGlobals globals = [&manifest] { + game::ObjectArrayGlobals resolved{}; + if (manifest.patterns.objectArrayGetShare.empty()) { + return resolved; + } + std::size_t matchCount = 0; + auto* function = core::find_unique_in_module( + nullptr, manifest.patterns.objectArrayGetShare, &matchCount); + if (!function) { + LOG_WARN("ARE animation scan disabled: GetShare pattern matched {} times", matchCount); + return resolved; + } + if (!game::decode_object_array_globals(static_cast(function), 0x60, + resolved)) { + LOG_WARN("ARE animation scan disabled: GetShare RIP operands did not decode"); + resolved = {}; + return resolved; + } + const auto moduleBase = reinterpret_cast(GetModuleHandleW(nullptr)); + LOG_INFO("CGameObjectArray resolved: GetShare RVA=0x{:X} (reference 0x{:X}), entries RVA=0x{:X}, " + "maxIndex RVA=0x{:X}", + reinterpret_cast(function) - moduleBase, + manifest.referenceRvas.objectArrayGetShare, + reinterpret_cast(resolved.entries) - moduleBase, + reinterpret_cast(resolved.maxArrayIndex) - moduleBase); + return resolved; + }(); + return globals; +} + +// Collects and classifies the active area's authored ARE ambient animations +// from the live CGameStatic objects in the engine's object array — fresh on +// every refresh (cheap, reflects script toggles and save state). Publication +// reuses the WED refresh generation so a stale result can never overwrite a +// newer area's snapshot. +void refresh_area_animations(AppContext& ctx, const game::CGameArea* area, + std::uint64_t refreshGeneration) noexcept { + if (!ctx.cfg.enableAreaAnimationScan || !area) { + return; + } + try { + game::ResrefBuffer areaResref{}; + if (!read_area_resref(area, areaResref) || game::resref_view(areaResref).empty()) { + LOG_DEBUG("ARE animation scan: active area resref unavailable"); + return; + } + + const auto& objectArray = resolved_object_array(*ctx.manifest); + if (!objectArray.valid()) { + return; // Already logged once at resolution time. + } + + game::AreaAnimationsInfo info{}; + if (!game::collect_area_static_animations(objectArray, area, info)) { + LOG_DEBUG("ARE animation walk failed for {}", game::resref_view(areaResref)); + return; + } + info.areaResref = areaResref; + // Merge with the render-thread envelope cache in both directions: a + // direct read seeds the cache, a cached box covers a released resource. + std::size_t replaced = 0; + std::size_t boxed = 0; + { + std::lock_guard envelopeLock(g_staticEnvelopeMutex); + for (auto& animation : info.animations) { + if (!animation.object || + !game::should_replace_animation_draw(animation.resrefView(), animation.kind)) { + continue; + } + ++replaced; + if (animation.envelope.valid) { + if (g_staticEnvelopes.size() < kMaxStaticEnvelopes) { + g_staticEnvelopes.insert_or_assign(animation.object, animation.envelope); + } + } else if (const auto cached = g_staticEnvelopes.find(animation.object); + cached != g_staticEnvelopes.end()) { + animation.envelope = cached->second; + } + if (animation.envelope.valid) ++boxed; + } + } + auto snapshot = std::make_shared(std::move(info)); + + // Pack the shader point set outside the commit lock; publish it together + // with the snapshot under the same generation gate. + std::vector effectPoints; + if (ctx.cfg.enablePointEffects) { + effectPoints = game::build_area_effect_points(*snapshot); + } + + { + std::lock_guard commitLock(g_areaRefreshCommitMutex); + if (g_areaRefreshGeneration.load(std::memory_order_acquire) != refreshGeneration) { + LOG_DEBUG("Discarding stale ARE animation refresh generation {}", refreshGeneration); + return; + } + ctx.areaAnimations.store(snapshot); + static_assert(sizeof(game::AreaEffectPoint) == 8 * sizeof(float)); + probe::set_area_effect_points( + effectPoints.empty() ? nullptr : &effectPoints.front().x, effectPoints.size()); + } + + // The walk reruns every refresh; log the summary once per area resref. + bool logSummary = false; + { + std::lock_guard logLock(g_areAnimationsLogMutex); + logSummary = g_lastLoggedAreResref != areaResref; + if (logSummary) g_lastLoggedAreResref = areaResref; + } + LOG_DEBUG("ARE effect points: {} published, {} of {} replaced statics use their authored " + "BAM box", + effectPoints.size(), boxed, replaced); + if (logSummary) { + log_area_animation_summary(*snapshot); + LOG_INFO("ARE effect points published: {} (pointEffects={})", effectPoints.size(), + ctx.cfg.enablePointEffects); + for (const auto& point : effectPoints) { + LOG_DEBUG("ARE effect point: kind={}, palette={}, pos=({}, {}), height={}, halfWidth={}", + point.kind, point.reserved1, point.x, point.y, point.height, point.halfWidth); + } + } + } catch (const std::exception& e) { + LOG_ERROR("ARE animation refresh failed: {}", e.what()); + } catch (...) { + LOG_ERROR("ARE animation refresh failed with an unknown exception"); + } +} + const game::CGameArea* read_loaded_area_candidate(const game::CGameArea* candidate) { if (!candidate) { return nullptr; @@ -175,6 +380,7 @@ void refresh_wed_cache(AppContext& ctx, void* infGame) { std::lock_guard commitLock(g_areaRefreshCommitMutex); refreshGeneration = g_areaRefreshGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; queue_no_liquid_snapshot(); + probe::set_area_effect_points(nullptr, 0); ctx.activeArea.store(nullptr); ctx.wed.store(std::shared_ptr{}); } @@ -198,6 +404,10 @@ void refresh_wed_cache(AppContext& ctx, void* infGame) { return; } + // Independent of WED parsing: a WED failure must not cost the authored + // ARE animation classification, and vice versa. + refresh_area_animations(ctx, area, refreshGeneration); + const auto* areaBytes = reinterpret_cast(area); game::CResWED* wedPointer = nullptr; if (!core::safe_read(areaBytes + offsetof(game::CGameArea, m_pResWED), wedPointer) || @@ -371,11 +581,47 @@ void refresh_wed_cache(AppContext& ctx, void* infGame) { } } +bool static_envelope_ready(const void* staticObject) noexcept { + if (!staticObject) return false; + try { + std::lock_guard envelopeLock(g_staticEnvelopeMutex); + if (g_staticEnvelopes.contains(staticObject)) return true; + game::BamEnvelope envelope{}; + if (g_staticEnvelopes.size() >= kMaxStaticEnvelopes || + !game::read_static_bam_envelope(staticObject, envelope)) { + return false; + } + g_staticEnvelopes.emplace(staticObject, envelope); + g_staticEnvelopesDirty.store(true, std::memory_order_release); + return true; + } catch (...) { + return false; + } +} + +void republish_area_animations_if_dirty(AppContext& ctx) noexcept { + if (!g_staticEnvelopesDirty.load(std::memory_order_acquire)) return; + // Statics come into view in bursts; coalesce them into one object walk. + static std::chrono::steady_clock::time_point lastRepublish{}; + const auto now = std::chrono::steady_clock::now(); + if (now - lastRepublish < kEnvelopeRepublishInterval) return; + lastRepublish = now; + g_staticEnvelopesDirty.store(false, std::memory_order_release); + refresh_area_animations(ctx, ctx.activeArea.load(), + g_areaRefreshGeneration.load(std::memory_order_acquire)); +} + void reset_gpu_area_state() noexcept { try { + { + std::lock_guard envelopeLock(g_staticEnvelopeMutex); + g_staticEnvelopes.clear(); + g_staticEnvelopesDirty.store(false, std::memory_order_release); + } std::lock_guard commitLock(g_areaRefreshCommitMutex); g_areaRefreshGeneration.fetch_add(1, std::memory_order_acq_rel); queue_no_liquid_snapshot(); + probe::set_area_effect_points(nullptr, 0); } catch (...) { // The previous immutable GPU snapshot remains valid until another // transition or refresh can publish a complete generation. diff --git a/src/iee/area_state.h b/src/iee/area_state.h index 0f6762e..a115182 100644 --- a/src/iee/area_state.h +++ b/src/iee/area_state.h @@ -30,6 +30,14 @@ bool read_view_transform(const game::CGameArea* area, ViewTransform& out); // Re-resolves the active area after LoadArea and caches its parsed WED into ctx. void refresh_wed_cache(AppContext& ctx, void* infGame); +// Render thread. True once the authored BAM draw box of a replaced static is +// known (cached, or readable now because the engine has loaded its BAM). The +// CGameStatic::Render hook keeps the engine draw until then, so the first +// engine draw loads the resource the box is read from. +bool static_envelope_ready(const void* staticObject) noexcept; +// Render thread. Rebuilds the effect points after new draw boxes were +// captured; a no-op otherwise. +void republish_area_animations_if_dirty(AppContext& ctx) noexcept; // Publishes an immediate CPU-side no-liquid generation. The next render // thread flush replaces any previous area's GPU mask before drawing. void reset_gpu_area_state() noexcept; diff --git a/src/iee/core/config.cpp b/src/iee/core/config.cpp index bef334a..b04496d 100644 --- a/src/iee/core/config.cpp +++ b/src/iee/core/config.cpp @@ -58,6 +58,20 @@ static void normalize(EngineConfig& cfg) noexcept { if (!std::isfinite(cfg.lodBias)) cfg.lodBias = -0.25f; cfg.maxAnisotropy = std::clamp(cfg.maxAnisotropy, 1.0f, 64.0f); cfg.lodBias = std::clamp(cfg.lodBias, -4.0f, 4.0f); + if (!std::isfinite(cfg.softFogRadius)) cfg.softFogRadius = 24.0f; + if (!std::isfinite(cfg.softFogDrift)) cfg.softFogDrift = 10.0f; + if (!std::isfinite(cfg.softFogSmoothing)) cfg.softFogSmoothing = 0.25f; + if (!std::isfinite(cfg.lightSpill)) cfg.lightSpill = 1.5f; + if (!std::isfinite(cfg.heatShimmer)) cfg.heatShimmer = 1.5f; + if (!std::isfinite(cfg.spriteSharpness)) cfg.spriteSharpness = 0.5f; + if (!std::isfinite(cfg.bloomStrength)) cfg.bloomStrength = 0.35f; + cfg.softFogRadius = std::clamp(cfg.softFogRadius, 0.0f, 256.0f); + cfg.softFogDrift = std::clamp(cfg.softFogDrift, 0.0f, 64.0f); + cfg.softFogSmoothing = std::clamp(cfg.softFogSmoothing, 0.0f, 2.0f); + cfg.lightSpill = std::clamp(cfg.lightSpill, 0.0f, 8.0f); + cfg.heatShimmer = std::clamp(cfg.heatShimmer, 0.0f, 12.0f); + cfg.spriteSharpness = std::clamp(cfg.spriteSharpness, 0.0f, 1.0f); + cfg.bloomStrength = std::clamp(cfg.bloomStrength, 0.0f, 2.0f); } static void apply_kv(EngineConfig& cfg, const std::string& section, const std::string& key, @@ -94,6 +108,36 @@ static void apply_kv(EngineConfig& cfg, const std::string& section, const std::s assign_float(cfg.maxAnisotropy); else if (iequals(key, "LODBias")) assign_float(cfg.lodBias); + else if (iequals(key, "SmoothSpriteMovement")) + assign_bool(cfg.smoothSpriteMovement); + else if (iequals(key, "InterpolateAnimations")) + assign_bool(cfg.interpolateAnimations); + else if (iequals(key, "SoftFogOfWar")) + assign_bool(cfg.softFogOfWar); + else if (iequals(key, "SoftFogRadius")) + assign_float(cfg.softFogRadius); + else if (iequals(key, "SoftFogDrift")) + assign_float(cfg.softFogDrift); + else if (iequals(key, "SoftFogSmoothing")) + assign_float(cfg.softFogSmoothing); + else if (iequals(key, "Bloom")) + assign_bool(cfg.bloom); + else if (iequals(key, "LightSpill")) + assign_float(cfg.lightSpill); + else if (iequals(key, "HeatShimmer")) + assign_float(cfg.heatShimmer); + else if (iequals(key, "SpriteUpscale")) + assign_bool(cfg.spriteUpscale); + else if (iequals(key, "SpriteSharpness")) + assign_float(cfg.spriteSharpness); + else if (iequals(key, "BloomStrength")) + assign_float(cfg.bloomStrength); + return; + } + + // [Detection] + if (iequals(section, "detection")) { + if (iequals(key, "AreaAnimationScan")) assign_bool(cfg.enableAreaAnimationScan); return; } @@ -105,6 +149,8 @@ static void apply_kv(EngineConfig& cfg, const std::string& section, const std::s assign_bool(cfg.enableDebugHotkeys); else if (iequals(key, "EnableWaterEffect")) assign_bool(cfg.enableWaterEffect); + else if (iequals(key, "EnablePointEffects")) + assign_bool(cfg.enablePointEffects); return; } } @@ -181,11 +227,27 @@ bool ConfigManager::save(const std::filesystem::path& path, const EngineConfig& write_bool(f, "EnableAnisotropicFiltering", cfg.enableAnisotropicFiltering); f << "MaxAnisotropy = " << cfg.maxAnisotropy << "\n"; f << "LODBias = " << cfg.lodBias << "\n"; + write_bool(f, "SmoothSpriteMovement", cfg.smoothSpriteMovement); + write_bool(f, "InterpolateAnimations", cfg.interpolateAnimations); + write_bool(f, "SoftFogOfWar", cfg.softFogOfWar); + f << "SoftFogRadius = " << cfg.softFogRadius << "\n"; + f << "SoftFogDrift = " << cfg.softFogDrift << "\n"; + f << "SoftFogSmoothing = " << cfg.softFogSmoothing << "\n"; + write_bool(f, "Bloom", cfg.bloom); + f << "LightSpill = " << cfg.lightSpill << "\n"; + f << "HeatShimmer = " << cfg.heatShimmer << "\n"; + write_bool(f, "SpriteUpscale", cfg.spriteUpscale); + f << "SpriteSharpness = " << cfg.spriteSharpness << "\n"; + f << "BloomStrength = " << cfg.bloomStrength << "\n"; + + write_section(f, "Detection"); + write_bool(f, "AreaAnimationScan", cfg.enableAreaAnimationScan); write_section(f, "Shaders"); write_bool(f, "DumpEngineShaders", cfg.dumpEngineShaders); write_bool(f, "EnableDebugHotkeys", cfg.enableDebugHotkeys); write_bool(f, "EnableWaterEffect", cfg.enableWaterEffect); + write_bool(f, "EnablePointEffects", cfg.enablePointEffects); return true; } diff --git a/src/iee/core/config.h b/src/iee/core/config.h index f6ca12b..6171358 100644 --- a/src/iee/core/config.h +++ b/src/iee/core/config.h @@ -7,10 +7,28 @@ struct EngineConfig { bool enableAnisotropicFiltering = false; float maxAnisotropy = 8.0f; float lodBias = -0.25f; + bool smoothSpriteMovement = false; + bool interpolateAnimations = false; + bool softFogOfWar = false; + float softFogRadius = 24.0f; // world pixels; 0 = capture without blur + float softFogDrift = 10.0f; // world pixels the soft edge drifts by; 0 = still + float softFogSmoothing = 0.25f; // seconds the fog takes to follow a visibility change + bool bloom = false; + float bloomStrength = 0.35f; + // How strongly light-emitting effects brighten what is around them; 0 = off. + float lightSpill = 1.5f; + // World pixels the air above warm light sources ripples by; 0 = off. + float heatShimmer = 1.5f; + // Edge-adaptive 2x upscale (FSR1) of the sprite atlas before it is drawn. + bool spriteUpscale = false; + float spriteSharpness = 0.5f; // 0 = no sharpening pass effect, 1 = strongest bool dumpEngineShaders = false; bool enableDebugHotkeys = false; bool enableWaterEffect = true; + bool enablePointEffects = true; + + bool enableAreaAnimationScan = true; bool enableVerboseLogging = false; bool enablePerformanceLogging = false; diff --git a/src/iee/core/gl_pass_guard.h b/src/iee/core/gl_pass_guard.h new file mode 100644 index 0000000..b228b7b --- /dev/null +++ b/src/iee/core/gl_pass_guard.h @@ -0,0 +1,82 @@ +#pragma once + +#include "iee/game/opengl_types.h" + +namespace iee::core { +// Saves the GL state our own draw passes change and puts it back on scope +// exit. The engine issues state changes relative to what it believes the +// hardware state is, so anything left altered corrupts its next draws. +// Covers program, vertex array, viewport, blending and the per-fragment tests. +// Framebuffer and texture bindings are not covered: the caller owns the +// framebuffer hand-over, and GlStateGuard covers texture units. +// Requires OpenGLFunctions::postProcessAvailable. +class GlPassGuard { + public: + GlPassGuard() noexcept { + const auto& gl = game::gl::get_gl_functions(); + gl.glGetIntegerv(game::gl::CURRENT_PROGRAM, &program_); + gl.glGetIntegerv(game::gl::VERTEX_ARRAY_BINDING, &vertexArray_); + gl.glGetIntegerv(game::gl::ARRAY_BUFFER_BINDING, &arrayBuffer_); + gl.glGetIntegerv(game::gl::VIEWPORT, viewport_); + gl.glGetIntegerv(game::gl::BLEND_SRC_RGB, &blendSrcRgb_); + gl.glGetIntegerv(game::gl::BLEND_DST_RGB, &blendDstRgb_); + gl.glGetIntegerv(game::gl::BLEND_SRC_ALPHA, &blendSrcAlpha_); + gl.glGetIntegerv(game::gl::BLEND_DST_ALPHA, &blendDstAlpha_); + gl.glGetFloatv(game::gl::COLOR_CLEAR_VALUE, clearColor_); + blend_ = gl.glIsEnabled(game::gl::BLEND) != 0; + scissor_ = gl.glIsEnabled(game::gl::SCISSOR_TEST) != 0; + depthTest_ = gl.glIsEnabled(game::gl::DEPTH_TEST) != 0; + cullFace_ = gl.glIsEnabled(game::gl::CULL_FACE) != 0; + stencilTest_ = gl.glIsEnabled(game::gl::STENCIL_TEST) != 0; + alphaTest_ = gl.glIsEnabled(game::gl::ALPHA_TEST) != 0; + } + + ~GlPassGuard() noexcept { + const auto& gl = game::gl::get_gl_functions(); + const auto set = [&](unsigned capability, bool enabled) { + if (enabled) { + gl.glEnable(capability); + } else { + gl.glDisable(capability); + } + }; + set(game::gl::BLEND, blend_); + set(game::gl::SCISSOR_TEST, scissor_); + set(game::gl::DEPTH_TEST, depthTest_); + set(game::gl::CULL_FACE, cullFace_); + set(game::gl::STENCIL_TEST, stencilTest_); + set(game::gl::ALPHA_TEST, alphaTest_); + gl.glBlendFuncSeparate( + static_cast(blendSrcRgb_), static_cast(blendDstRgb_), + static_cast(blendSrcAlpha_), static_cast(blendDstAlpha_)); + gl.glClearColor(clearColor_[0], clearColor_[1], clearColor_[2], clearColor_[3]); + gl.glViewport(viewport_[0], viewport_[1], viewport_[2], viewport_[3]); + gl.glBindVertexArray(static_cast(vertexArray_)); + gl.glBindBuffer(game::gl::ARRAY_BUFFER, static_cast(arrayBuffer_)); + gl.glUseProgram(static_cast(program_)); + } + + GlPassGuard(const GlPassGuard&) = delete; + GlPassGuard& operator=(const GlPassGuard&) = delete; + + // The viewport as the engine left it: {x, y, width, height}. + [[nodiscard]] const int* viewport() const noexcept { return viewport_; } + + private: + int program_{}; + int vertexArray_{}; + int arrayBuffer_{}; + int viewport_[4]{}; + int blendSrcRgb_{}; + int blendDstRgb_{}; + int blendSrcAlpha_{}; + int blendDstAlpha_{}; + float clearColor_[4]{}; + bool blend_{}; + bool scissor_{}; + bool depthTest_{}; + bool cullFace_{}; + bool stencilTest_{}; + bool alphaTest_{}; +}; +} // namespace iee::core diff --git a/src/iee/features/tile_render.cpp b/src/iee/features/tile_render.cpp index 3ca5bcb..f258cd8 100644 --- a/src/iee/features/tile_render.cpp +++ b/src/iee/features/tile_render.cpp @@ -103,17 +103,24 @@ bool render_tile(AppContext& ctx, void* vidTile, int texId, void* unused, int x, detection->scaleFactor, reinterpret_cast(tileInfo.tileset), detection->detectedTileDimension); break; - case game::ScaleDetectionSource::Heuristic: - LOG_INFO("Detected {}x tileset 0x{:X} via heuristic fallback (texId={}, UV=({}, {}))", - detection->scaleFactor, reinterpret_cast(tileInfo.tileset), - texId, entry.u, entry.v); - break; } if (detection->scaleFactor == 1) { state.lastTexId.store(-1, std::memory_order_relaxed); return false; } + } else if (tileInfo.table && tileInfo.tileCount > 0) { + // The resource is resident and readable yet exposes no deterministic + // scale metadata (classic paletted tilesets, door tiles): that cannot + // change with more draws, so latch standard immediately instead of + // burning the sampling window. Sampling below remains only for + // resources still streaming in. + tilesetState->scaleFactor = 1; + tilesetState->scaleDetected = true; + LOG_INFO("Tileset 0x{:X} has no deterministic scale metadata; delegated as standard", + reinterpret_cast(tileInfo.tileset)); + state.lastTexId.store(-1, std::memory_order_relaxed); + return false; } else if (tilesetState->detectionCount < game::UpscaleThresholds::DETECTION_SAMPLE_COUNT) { const int sampleCount = ++tilesetState->detectionCount; if (sampleCount == 1) { diff --git a/src/iee/features/world_post.cpp b/src/iee/features/world_post.cpp new file mode 100644 index 0000000..4832828 --- /dev/null +++ b/src/iee/features/world_post.cpp @@ -0,0 +1,1141 @@ +#include "iee/features/world_post.h" + +#include + +#include +#include +#include +#include + +#include "iee/core/config.h" +#include "iee/core/gl_pass_guard.h" +#include "iee/core/gl_state_guard.h" +#include "iee/core/logger.h" +#include "iee/game/opengl_types.h" +#include "iee/game/world_post_plan.h" + +namespace iee::features { +namespace { +namespace gl = game::gl; + +struct Target { + unsigned texture{}; + unsigned framebuffer{}; + game::Extent extent{}; +}; + +struct CompositeProgram { + unsigned id{}; + int scale{-1}; + int dither{-1}; + int curve{-1}; +}; + +// Blends the new fog image with the one shown last frame, looked up at the +// same place on the map. +struct FogTemporalProgram { + unsigned id{}; + int origin{-1}; + int size{-1}; + int previousOrigin{-1}; + int previousSize{-1}; + int blend{-1}; +}; + +struct SharpenProgram { + unsigned id{}; + int sharpness{-1}; +}; + +// One sprite atlas's upscaled copies: the edge-adaptive 2x image and the +// sharpened result the engine then draws from. +struct AtlasTargets { + Target upscaled{}; + Target sharpened{}; +}; + +struct ShimmerProgram { + unsigned id{}; + int worldOrigin{-1}; + int worldSize{-1}; + int strength{-1}; + int time{-1}; +}; + +struct FogProgram { + unsigned id{}; + int worldOrigin{-1}; + int worldSize{-1}; + int drift{-1}; + int time{-1}; +}; + +struct BlurProgram { + unsigned id{}; + int step{-1}; +}; + +// Level i is the base image halved i + 1 times. +struct BlurChain { + std::array levels{}; + int count{}; +}; + +struct Settings { + bool softFog{}; + bool bloom{}; + float fogRadius{}; + float bloomStrength{}; + float lightSpill{}; + float fogDrift{}; + float fogSmoothing{}; + float heatShimmer{}; + bool spriteUpscale{}; + float spriteSharpness{}; +}; + +// Everything that lives and dies with one GL context. +struct Resources { + HGLRC context{}; + game::Extent viewport{}; + unsigned vertexArray{}; + unsigned vertexBuffer{}; + CompositeProgram composite{}; + FogProgram fog{}; + FogTemporalProgram fogTemporal{}; + ShimmerProgram shimmer{}; + unsigned spriteUpscale{}; + SharpenProgram spriteSharpen{}; + std::array atlases{}; + std::array atlasDrawnOnce{}; + BlurProgram blurDown{}; + BlurProgram blurUp{}; + Target fogCapture{}; + BlurChain fogChain{}; + // The fog as shown last frame and the one being built, swapped each frame. + std::array fogHistory{}; + int fogHistoryCurrent{}; + bool fogHistoryValid{}; + WorldView fogHistoryView{}; + std::chrono::steady_clock::time_point fogHistoryTime{}; + // The engine's additive (light-emitting) draws of this frame, on black. + Target emissive{}; + Target emissiveHalf{}; + BlurChain bloomChain{}; + Target sceneCopy{}; // the world image, read back while it is redrawn rippled + bool fogDrawnOnce{}; + bool bloomDrawnOnce{}; +}; + +Settings g_settings; +Resources g_resources; +bool g_failed = false; +int g_engineDrawFramebuffer = 0; +int g_engineReadFramebuffer = 0; +float g_pixelsPerWorldPixel = 1.0f; +WorldView g_view{}; +std::atomic g_logNextFrame{true}; +std::atomic g_dropFogHistory{false}; +// Frame bookkeeping for the emissive capture. The world pass runs from the +// frame boundary to the fog; additive draws queued later belong to the UI. +unsigned g_frame = 1; +unsigned g_emissiveFrame = 0; +game::WorldPassGate g_worldPass; +// Field counters for the per-area log line: frame boundaries seen and sprite +// atlases upscaled since the previous area frame. +int g_frameTicksSinceArea = 0; +int g_atlasUpscalesSinceArea = 0; +int g_emissiveCommands = 0; +int g_savedDrawFramebuffer = 0; +int g_savedReadFramebuffer = 0; + +constexpr const char* kVertexSource = R"glsl(#version 330 +layout(location = 0) in vec2 aPosition; +out vec2 vUv; +void main() { + vUv = aPosition * 0.5 + 0.5; + gl_Position = vec4(aPosition, 0.0, 1.0); +} +)glsl"; + +// Smooth 2D value noise in [0, 1], shared by the shaders that animate. It is +// handed to the compiler as a second source string after the shader that +// declares `float noise(vec2 p);`. +constexpr const char* kValueNoiseSource = R"glsl( +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} +float noise(vec2 p) { + vec2 cell = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x), + mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x), f.y); +} +)glsl"; + +// Writes the texture scaled by uScale; the caller's blend mode decides how it +// lands. uDither adds +-0.5/255 of ordered noise to hide banding in long dark +// gradients. With uCurve set the value is treated as light and mapped through +// 1 - exp(-x): faint light far from a source still registers, and light next +// to the source saturates instead of clipping. +constexpr const char* kCompositeSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform float uScale; +uniform float uDither; +uniform float uCurve; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec3 color = texture(uTexture, vUv).rgb * uScale; + if (uCurve > 0.5) color = vec3(1.0) - exp(-color); + float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + fragColor = vec4(color + uDither * (noise - 0.5) / 255.0, 1.0); +} +)glsl"; + +// The engine recomputes visibility on its logic tick, so the fog would step +// while everything else moves smoothly. This moves the shown fog a fraction +// (uBlend) of the way to the new one each frame. The previous image is read +// at the same world position, so scrolling and zooming do not smear it; +// where that position was off screen the new image is used as is. +constexpr const char* kFogTemporalSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform sampler2D uHistory; +uniform vec2 uOrigin; +uniform vec2 uSize; +uniform vec2 uPreviousOrigin; +uniform vec2 uPreviousSize; +uniform float uBlend; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec3 current = texture(uTexture, vUv).rgb; + vec2 world = uOrigin + vec2(vUv.x, 1.0 - vUv.y) * uSize; + vec2 previous = (world - uPreviousOrigin) / uPreviousSize; + previous.y = 1.0 - previous.y; + float inside = step(0.0, previous.x) * step(previous.x, 1.0) * step(0.0, previous.y) * + step(previous.y, 1.0); + vec3 history = texture(uHistory, previous).rgb; + fragColor = vec4(mix(current, history, inside * (1.0 - uBlend)), 1.0); +} +)glsl"; + +// Multiplies the scene by the blurred fog capture (white = fully visible). +// The lookup is pushed around by slow noise anchored to the world, so the +// soft edge drifts like mist; where the capture is flat the push changes +// nothing, so explored and unexplored areas keep the engine's exact shade. +constexpr const char* kFogSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform vec2 uWorldOrigin; +uniform vec2 uWorldSize; +uniform float uDrift; +uniform float uTime; +in vec2 vUv; +out vec4 fragColor; +float noise(vec2 p); +void main() { + vec2 uv = vUv; + if (uDrift > 0.0 && uWorldSize.x > 0.0 && uWorldSize.y > 0.0) { + vec2 world = uWorldOrigin + vec2(vUv.x, 1.0 - vUv.y) * uWorldSize; + vec2 p = world / 96.0; + vec2 push = vec2(noise(p + vec2(uTime * 0.07, uTime * 0.05)) + + 0.5 * noise(p * 2.3 - vec2(uTime * 0.11, 0.0)), + noise(p + vec2(31.7, 17.3) - vec2(uTime * 0.06, uTime * 0.08)) + + 0.5 * noise(p * 2.3 + vec2(5.1, uTime * 0.09))) / 1.5 - 0.5; + uv += push * 2.0 * uDrift / uWorldSize; + } + vec3 visible = texture(uTexture, uv).rgb; + float grain = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + fragColor = vec4(visible + (grain - 0.5) / 255.0, 1.0); +} +)glsl"; + +// FSR1 EASU (edge-adaptive spatial upsampling), after AMD's FidelityFX +// reference (MIT): a 12-tap kernel whose shape follows the local edge +// direction, so diagonal outlines come out smooth instead of stair-stepped. +// Runs on the sprite atlas at exactly 2x, texel to texel: output pixel +// gl_FragCoord maps to input position gl_FragCoord / 2. Alpha is filtered +// with the colour and counts towards the edge detection, because a sprite's +// outline is mostly an alpha edge. +constexpr const char* kSpriteUpscaleSource = R"glsl(#version 330 +uniform sampler2D uTexture; +out vec4 fragColor; +ivec2 size; +vec4 tap(ivec2 p) { + return texelFetch(uTexture, clamp(p, ivec2(0), size - 1), 0); +} +float luma(vec4 c) { + return c.r * 0.5 + c.g + c.b * 0.5 + c.a; +} +void accumulate(inout vec4 colour, inout float weight, vec2 offset, vec2 dir, vec2 len, float lobe, + float clip, vec4 c) { + vec2 v = vec2(offset.x * dir.x + offset.y * dir.y, offset.x * -dir.y + offset.y * dir.x) * len; + float d2 = min(dot(v, v), clip); + float wB = 0.4 * d2 - 1.0; + float wA = lobe * d2 - 1.0; + wB *= wB; + wA *= wA; + wB = 1.5625 * wB - 0.5625; + float w = wB * wA; + colour += c * w; + weight += w; +} +void edge(inout vec2 dir, inout float len, float w, float lA, float lB, float lC, float lD, + float lE) { + float lenX = max(abs(lD - lC), abs(lC - lB)); + float dirX = lD - lB; + dir.x += dirX * w; + lenX = clamp(abs(dirX) / max(lenX, 1e-5), 0.0, 1.0); + len += lenX * lenX * w; + float lenY = max(abs(lE - lC), abs(lC - lA)); + float dirY = lE - lA; + dir.y += dirY * w; + lenY = clamp(abs(dirY) / max(lenY, 1e-5), 0.0, 1.0); + len += lenY * lenY * w; +} +void main() { + size = textureSize(uTexture, 0); + vec2 pp = gl_FragCoord.xy * 0.5 - 0.5; + vec2 fp = floor(pp); + pp -= fp; + ivec2 ip = ivec2(fp); + // b c + // e f g h + // i j k l + // n o + vec4 b = tap(ip + ivec2(0, -1)); + vec4 c = tap(ip + ivec2(1, -1)); + vec4 e = tap(ip + ivec2(-1, 0)); + vec4 f = tap(ip); + vec4 g = tap(ip + ivec2(1, 0)); + vec4 h = tap(ip + ivec2(2, 0)); + vec4 i = tap(ip + ivec2(-1, 1)); + vec4 j = tap(ip + ivec2(0, 1)); + vec4 k = tap(ip + ivec2(1, 1)); + vec4 l = tap(ip + ivec2(2, 1)); + vec4 n = tap(ip + ivec2(0, 2)); + vec4 o = tap(ip + ivec2(1, 2)); + float bL = luma(b), cL = luma(c), eL = luma(e), fL = luma(f), gL = luma(g), hL = luma(h); + float iL = luma(i), jL = luma(j), kL = luma(k), lL = luma(l), nL = luma(n), oL = luma(o); + vec2 dir = vec2(0.0); + float len = 0.0; + edge(dir, len, (1.0 - pp.x) * (1.0 - pp.y), bL, eL, fL, gL, jL); + edge(dir, len, pp.x * (1.0 - pp.y), cL, fL, gL, hL, kL); + edge(dir, len, (1.0 - pp.x) * pp.y, fL, iL, jL, kL, nL); + edge(dir, len, pp.x * pp.y, gL, jL, kL, lL, oL); + float dirR = dot(dir, dir); + bool flat_ = dirR < 1.0 / 32768.0; + dir = flat_ ? vec2(1.0, 0.0) : dir * inversesqrt(dirR); + len = len * 0.5; + len *= len; + float stretch = dot(dir, dir) / max(abs(dir.x), abs(dir.y)); + vec2 len2 = vec2(1.0 + (stretch - 1.0) * len, 1.0 - 0.5 * len); + float lobe = 0.5 + (0.21 - 0.5) * len; + float clip = 1.0 / lobe; + vec4 lowest = min(min(f, g), min(j, k)); + vec4 highest = max(max(f, g), max(j, k)); + vec4 colour = vec4(0.0); + float weight = 0.0; + accumulate(colour, weight, vec2(0.0, -1.0) - pp, dir, len2, lobe, clip, b); + accumulate(colour, weight, vec2(1.0, -1.0) - pp, dir, len2, lobe, clip, c); + accumulate(colour, weight, vec2(-1.0, 1.0) - pp, dir, len2, lobe, clip, i); + accumulate(colour, weight, vec2(0.0, 1.0) - pp, dir, len2, lobe, clip, j); + accumulate(colour, weight, vec2(0.0, 0.0) - pp, dir, len2, lobe, clip, f); + accumulate(colour, weight, vec2(-1.0, 0.0) - pp, dir, len2, lobe, clip, e); + accumulate(colour, weight, vec2(1.0, 1.0) - pp, dir, len2, lobe, clip, k); + accumulate(colour, weight, vec2(2.0, 1.0) - pp, dir, len2, lobe, clip, l); + accumulate(colour, weight, vec2(2.0, 0.0) - pp, dir, len2, lobe, clip, h); + accumulate(colour, weight, vec2(1.0, 0.0) - pp, dir, len2, lobe, clip, g); + accumulate(colour, weight, vec2(1.0, 2.0) - pp, dir, len2, lobe, clip, o); + accumulate(colour, weight, vec2(0.0, 2.0) - pp, dir, len2, lobe, clip, n); + fragColor = clamp(colour / weight, lowest, highest); +} +)glsl"; + +// FSR1 RCAS (robust contrast-adaptive sharpening), same source: sharpens as +// far as it can without pushing any channel outside its neighbourhood's range. +constexpr const char* kSpriteSharpenSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform float uSharpness; +out vec4 fragColor; +void main() { + ivec2 size = textureSize(uTexture, 0); + ivec2 p = ivec2(gl_FragCoord.xy); + vec4 b = texelFetch(uTexture, clamp(p + ivec2(0, -1), ivec2(0), size - 1), 0); + vec4 d = texelFetch(uTexture, clamp(p + ivec2(-1, 0), ivec2(0), size - 1), 0); + vec4 e = texelFetch(uTexture, p, 0); + vec4 f = texelFetch(uTexture, clamp(p + ivec2(1, 0), ivec2(0), size - 1), 0); + vec4 h = texelFetch(uTexture, clamp(p + ivec2(0, 1), ivec2(0), size - 1), 0); + vec4 lowest = min(min(b, d), min(f, h)); + vec4 highest = max(max(b, d), max(f, h)); + vec4 hitMin = min(lowest, e) / max(4.0 * highest, vec4(1e-4)); + vec4 hitMax = (vec4(1.0) - max(highest, e)) / min(4.0 * lowest - 4.0, vec4(-1e-4)); + vec4 lobes = max(-hitMin, hitMax); + float lobe = max(-0.1875, min(max(max(lobes.r, lobes.g), max(lobes.b, lobes.a)), 0.0)) * + uSharpness; + fragColor = clamp((lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0), 0.0, 1.0); +} +)glsl"; + +// Heat haze. Redraws the scene with its lookup rippled where the blurred +// emissive image (uMask) is warm, i.e. redder than it is blue, so fires and +// torches shimmer and cold or white magic does not. The mask is also read +// from below each pixel, because hot air rises above its source. The ripple +// is anchored to the world and flows upward. +constexpr const char* kShimmerSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform sampler2D uMask; +uniform vec2 uWorldOrigin; +uniform vec2 uWorldSize; +uniform float uStrength; +uniform float uTime; +in vec2 vUv; +out vec4 fragColor; +float noise(vec2 p); +float warmth(vec2 uv) { + vec3 light = texture(uMask, uv).rgb; + return light.r - light.b; +} +void main() { + vec2 rise = vec2(0.0, 26.0 / uWorldSize.y); + float heat = max(warmth(vUv), max(0.8 * warmth(vUv - rise), 0.5 * warmth(vUv - 2.0 * rise))); + heat = clamp(heat * 4.0, 0.0, 1.0); + vec2 world = uWorldOrigin + vec2(vUv.x, 1.0 - vUv.y) * uWorldSize; + vec2 p = world / 9.0; + vec2 ripple = vec2(noise(p + vec2(0.0, uTime * 2.2)), noise(p * 1.3 + vec2(7.0, uTime * 2.8))) - 0.5; + vec2 offset = ripple * 2.0 * uStrength * heat / uWorldSize; + fragColor = vec4(texture(uTexture, vUv + offset).rgb, 1.0); +} +)glsl"; + +// Dual-filter blur (down then up through half-size levels). uStep is the +// sample spread in source UV units. +constexpr const char* kBlurDownSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform vec2 uStep; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec4 sum = texture(uTexture, vUv) * 4.0; + sum += texture(uTexture, vUv - uStep); + sum += texture(uTexture, vUv + uStep); + sum += texture(uTexture, vUv + vec2(uStep.x, -uStep.y)); + sum += texture(uTexture, vUv - vec2(uStep.x, -uStep.y)); + fragColor = sum / 8.0; +} +)glsl"; + +constexpr const char* kBlurUpSource = R"glsl(#version 330 +uniform sampler2D uTexture; +uniform vec2 uStep; +in vec2 vUv; +out vec4 fragColor; +void main() { + vec4 sum = texture(uTexture, vUv + vec2(-uStep.x * 2.0, 0.0)); + sum += texture(uTexture, vUv + vec2(-uStep.x, uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(0.0, uStep.y * 2.0)); + sum += texture(uTexture, vUv + vec2(uStep.x, uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(uStep.x * 2.0, 0.0)); + sum += texture(uTexture, vUv + vec2(uStep.x, -uStep.y)) * 2.0; + sum += texture(uTexture, vUv + vec2(0.0, -uStep.y * 2.0)); + sum += texture(uTexture, vUv + vec2(-uStep.x, -uStep.y)) * 2.0; + fragColor = sum / 12.0; +} +)glsl"; + +void fail(const char* reason) noexcept { + g_failed = true; + try { + LOG_WARN("Soft fog of war and bloom disabled for this session: {}", reason); + } catch (...) { + } +} + +// `appended` is an optional second source string (shared helper functions). +unsigned compile_shader(unsigned type, const char* source, + const char* appended = nullptr) noexcept { + const auto& fn = gl::get_gl_functions(); + const unsigned shader = fn.glCreateShader(type); + if (!shader) return 0; + const char* sources[2] = {source, appended}; + fn.glShaderSource(shader, appended ? 2 : 1, sources, nullptr); + fn.glCompileShader(shader); + int status = 0; + fn.glGetShaderiv(shader, gl::COMPILE_STATUS, &status); + if (!status) { + char log[512]{}; + fn.glGetShaderInfoLog(shader, static_cast(sizeof(log)) - 1, nullptr, log); + try { + LOG_WARN("World post shader compile failed: {}", log); + } catch (...) { + } + fn.glDeleteShader(shader); + return 0; + } + return shader; +} + +unsigned build_program(const char* fragmentSource, const char* appended = nullptr) noexcept { + const auto& fn = gl::get_gl_functions(); + const unsigned vertex = compile_shader(gl::VERTEX_SHADER, kVertexSource); + const unsigned fragment = compile_shader(gl::FRAGMENT_SHADER, fragmentSource, appended); + unsigned program = 0; + if (vertex && fragment) { + program = fn.glCreateProgram(); + fn.glAttachShader(program, vertex); + fn.glAttachShader(program, fragment); + fn.glLinkProgram(program); + int status = 0; + fn.glGetProgramiv(program, gl::LINK_STATUS, &status); + if (!status) { + char log[512]{}; + fn.glGetProgramInfoLog(program, static_cast(sizeof(log)) - 1, nullptr, log); + try { + LOG_WARN("World post program link failed: {}", log); + } catch (...) { + } + fn.glDeleteProgram(program); + program = 0; + } + } + if (vertex) fn.glDeleteShader(vertex); + if (fragment) fn.glDeleteShader(fragment); + if (program) { + fn.glUseProgram(program); + fn.glUniform1i(fn.glGetUniformLocation(program, "uTexture"), 0); + } + return program; +} + +void destroy_target(Target& target) noexcept { + const auto& fn = gl::get_gl_functions(); + if (target.framebuffer) fn.glDeleteFramebuffers(1, &target.framebuffer); + if (target.texture) fn.glDeleteTextures(1, &target.texture); + target = {}; +} + +// Leaves the new framebuffer and texture bound; the caller's guards restore. +bool create_target(Target& target, game::Extent extent, unsigned internalFormat, + unsigned type) noexcept { + const auto& fn = gl::get_gl_functions(); + destroy_target(target); + fn.glGenTextures(1, &target.texture); + fn.glBindTexture(gl::TEXTURE_2D, target.texture); + fn.glTexImage2D(gl::TEXTURE_2D, 0, static_cast(internalFormat), extent.width, + extent.height, 0, gl::RGBA, type, nullptr); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, static_cast(gl::LINEAR)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, static_cast(gl::LINEAR)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, static_cast(gl::CLAMP_TO_EDGE)); + fn.glTexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, static_cast(gl::CLAMP_TO_EDGE)); + fn.glGenFramebuffers(1, &target.framebuffer); + fn.glBindFramebuffer(gl::FRAMEBUFFER, target.framebuffer); + fn.glFramebufferTexture2D(gl::FRAMEBUFFER, gl::COLOR_ATTACHMENT0, gl::TEXTURE_2D, + target.texture, 0); + if (fn.glCheckFramebufferStatus(gl::FRAMEBUFFER) != gl::FRAMEBUFFER_COMPLETE) { + destroy_target(target); + return false; + } + target.extent = extent; + return true; +} + +void destroy_chain(BlurChain& chain) noexcept { + for (auto& level : chain.levels) destroy_target(level); + chain.count = 0; +} + +bool create_chain(BlurChain& chain, game::Extent base) noexcept { + destroy_chain(chain); + const int count = game::available_blur_levels(base); + game::Extent extent = base; + for (int index = 0; index < count; ++index) { + extent = game::half_extent(extent); + if (!create_target(chain.levels[static_cast(index)], extent, gl::RGBA16F, + gl::HALF_FLOAT)) { + destroy_chain(chain); + return false; + } + } + chain.count = count; + return true; +} + +void bind_engine_framebuffer() noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, static_cast(g_engineDrawFramebuffer)); + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, static_cast(g_engineReadFramebuffer)); +} + +// State every pass of ours draws with. The guards in scope restore it. +void set_pass_state() noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glDisable(gl::SCISSOR_TEST); + fn.glDisable(gl::DEPTH_TEST); + fn.glDisable(gl::CULL_FACE); + fn.glDisable(gl::STENCIL_TEST); + fn.glDisable(gl::ALPHA_TEST); + fn.glDisable(gl::BLEND); + fn.glBindVertexArray(g_resources.vertexArray); + fn.glActiveTexture(gl::TEXTURE0); +} + +// Wrapped hourly so the float keeps sub-frame precision. +float animation_seconds() noexcept { + return static_cast(GetTickCount64() % 3600000ULL) / 1000.0f; +} + +void draw_composite(unsigned texture, float scale, float dither, bool lightCurve = false) noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glUseProgram(g_resources.composite.id); + fn.glUniform1f(g_resources.composite.scale, scale); + fn.glUniform1f(g_resources.composite.dither, dither); + fn.glUniform1f(g_resources.composite.curve, lightCurve ? 1.0f : 0.0f); + fn.glBindTexture(gl::TEXTURE_2D, texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); +} + +void blur_pass(const BlurProgram& program, const Target& source, const Target& destination, + float stepTexels) noexcept { + const auto& fn = gl::get_gl_functions(); + fn.glBindFramebuffer(gl::FRAMEBUFFER, destination.framebuffer); + fn.glViewport(0, 0, destination.extent.width, destination.extent.height); + fn.glUseProgram(program.id); + fn.glUniform2f(program.step, stepTexels / static_cast(source.extent.width), + stepTexels / static_cast(source.extent.height)); + fn.glBindTexture(gl::TEXTURE_2D, source.texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); +} + +// Blurs `source` into chain.levels[0]. With `accumulate`, each level keeps its +// own downsampled content and the wider blur is added on top (bloom); without +// it the result is a plain blur (fog). Returns the number of levels used. +// Requires set_pass_state(); leaves blending off. +int run_blur(const Target& source, BlurChain& chain, game::BlurPlan plan, + bool accumulate) noexcept { + const auto& fn = gl::get_gl_functions(); + const int levels = plan.levels < chain.count ? plan.levels : chain.count; + if (levels <= 0) return 0; + fn.glDisable(gl::BLEND); + const Target* from = &source; + for (int index = 0; index < levels; ++index) { + const auto& to = chain.levels[static_cast(index)]; + blur_pass(g_resources.blurDown, *from, to, plan.offset); + from = &to; + } + if (accumulate) { + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::ONE, gl::ONE); + } + for (int index = levels - 1; index > 0; --index) { + blur_pass(g_resources.blurUp, chain.levels[static_cast(index)], + chain.levels[static_cast(index - 1)], plan.offset * 0.5f); + } + fn.glDisable(gl::BLEND); + return levels; +} + +bool create_shared_resources() noexcept { + const auto& fn = gl::get_gl_functions(); + static constexpr float kTriangle[] = {-1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f}; + fn.glGenVertexArrays(1, &g_resources.vertexArray); + fn.glGenBuffers(1, &g_resources.vertexBuffer); + fn.glBindVertexArray(g_resources.vertexArray); + fn.glBindBuffer(gl::ARRAY_BUFFER, g_resources.vertexBuffer); + fn.glBufferData(gl::ARRAY_BUFFER, static_cast(sizeof(kTriangle)), kTriangle, + gl::STATIC_DRAW); + fn.glEnableVertexAttribArray(0); + fn.glVertexAttribPointer(0, 2, gl::FLOAT, 0, 0, nullptr); + + auto& resources = g_resources; + resources.composite.id = build_program(kCompositeSource); + resources.blurDown.id = build_program(kBlurDownSource); + resources.blurUp.id = build_program(kBlurUpSource); + resources.fog.id = build_program(kFogSource, kValueNoiseSource); + resources.fogTemporal.id = build_program(kFogTemporalSource); + if (!resources.composite.id || !resources.blurDown.id || !resources.blurUp.id || + !resources.fog.id || !resources.fogTemporal.id) { + return false; + } + resources.shimmer.id = build_program(kShimmerSource, kValueNoiseSource); + resources.spriteUpscale = build_program(kSpriteUpscaleSource); + resources.spriteSharpen.id = build_program(kSpriteSharpenSource); + if (!resources.shimmer.id || !resources.spriteUpscale || !resources.spriteSharpen.id) { + return false; + } + resources.spriteSharpen.sharpness = + fn.glGetUniformLocation(resources.spriteSharpen.id, "uSharpness"); + auto& shimmer = resources.shimmer; + shimmer.worldOrigin = fn.glGetUniformLocation(shimmer.id, "uWorldOrigin"); + shimmer.worldSize = fn.glGetUniformLocation(shimmer.id, "uWorldSize"); + shimmer.strength = fn.glGetUniformLocation(shimmer.id, "uStrength"); + shimmer.time = fn.glGetUniformLocation(shimmer.id, "uTime"); + fn.glUseProgram(shimmer.id); + fn.glUniform1i(fn.glGetUniformLocation(shimmer.id, "uMask"), 1); + auto& temporal = resources.fogTemporal; + temporal.origin = fn.glGetUniformLocation(temporal.id, "uOrigin"); + temporal.size = fn.glGetUniformLocation(temporal.id, "uSize"); + temporal.previousOrigin = fn.glGetUniformLocation(temporal.id, "uPreviousOrigin"); + temporal.previousSize = fn.glGetUniformLocation(temporal.id, "uPreviousSize"); + temporal.blend = fn.glGetUniformLocation(temporal.id, "uBlend"); + fn.glUseProgram(temporal.id); + fn.glUniform1i(fn.glGetUniformLocation(temporal.id, "uHistory"), 1); + resources.composite.curve = fn.glGetUniformLocation(resources.composite.id, "uCurve"); + resources.fog.worldOrigin = fn.glGetUniformLocation(resources.fog.id, "uWorldOrigin"); + resources.fog.worldSize = fn.glGetUniformLocation(resources.fog.id, "uWorldSize"); + resources.fog.drift = fn.glGetUniformLocation(resources.fog.id, "uDrift"); + resources.fog.time = fn.glGetUniformLocation(resources.fog.id, "uTime"); + resources.composite.scale = fn.glGetUniformLocation(resources.composite.id, "uScale"); + resources.composite.dither = fn.glGetUniformLocation(resources.composite.id, "uDither"); + resources.blurDown.step = fn.glGetUniformLocation(resources.blurDown.id, "uStep"); + resources.blurUp.step = fn.glGetUniformLocation(resources.blurUp.id, "uStep"); + return true; +} + +bool create_sized_resources(game::Extent viewport) noexcept { + auto& resources = g_resources; + const auto half = game::half_extent(viewport); + if (!create_target(resources.fogCapture, viewport, gl::RGBA8, gl::UNSIGNED_BYTE)) return false; + if (!create_chain(resources.fogChain, viewport)) return false; + for (auto& history : resources.fogHistory) { + if (!create_target(history, half, gl::RGBA16F, gl::HALF_FLOAT)) return false; + } + resources.fogHistoryValid = false; + if (!create_target(resources.emissive, viewport, gl::RGBA8, gl::UNSIGNED_BYTE)) return false; + if (!create_target(resources.emissiveHalf, half, gl::RGBA16F, gl::HALF_FLOAT)) return false; + if (!create_target(resources.sceneCopy, viewport, gl::RGBA8, gl::UNSIGNED_BYTE)) return false; + if (!create_chain(resources.bloomChain, half)) return false; + resources.viewport = viewport; + return true; +} + +// Programs and the triangle, per GL context. Called inside the guards' scope: +// creation changes bindings. +bool ensure_shared_resources() noexcept { + const auto context = gl::current_context(); + // Creation binds textures; keep that on unit 0, which the caller's guard restores. + gl::get_gl_functions().glActiveTexture(gl::TEXTURE0); + if (context != g_resources.context) { + // The old context is gone and took its objects with it. + g_resources = {}; + g_resources.context = context; + gl::discard_errors(); + if (!create_shared_resources() || !gl::check_error("world post shared resources")) { + return false; + } + } + return true; +} + +// Called inside the guards' scope: creation changes bindings. +bool ensure_resources(game::Extent viewport) noexcept { + if (!ensure_shared_resources()) return false; + if (!(viewport == g_resources.viewport)) { + gl::discard_errors(); + if (!create_sized_resources(viewport) || !gl::check_error("world post targets")) return false; + g_logNextFrame.store(true, std::memory_order_relaxed); + } + return true; +} + +// Glow and light spill from the frame's emissive image. Runs before the fog +// so unexplored areas stay dark, and before the UI is drawn so the UI cannot +// glow. Requires set_pass_state(). +void draw_bloom() noexcept { + const auto& fn = gl::get_gl_functions(); + auto& resources = g_resources; + if (g_emissiveFrame != g_frame) return; // nothing emitted light this frame + const auto half = resources.emissiveHalf.extent; + const int planned = game::bloom_levels(half); + if (planned <= 0) return; + + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, resources.emissive.framebuffer); + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, resources.emissiveHalf.framebuffer); + fn.glBlitFramebuffer(0, 0, resources.viewport.width, resources.viewport.height, 0, 0, + half.width, half.height, gl::COLOR_BUFFER_BIT, gl::LINEAR); + const int levels = + run_blur(resources.emissiveHalf, resources.bloomChain, {planned, 1.0f}, true); + + if (levels > 0 && g_settings.heatShimmer > 0.0f) { + // Before the glow is added, so the haze bends the scene and not the halo. + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, static_cast(g_engineDrawFramebuffer)); + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, resources.sceneCopy.framebuffer); + fn.glBlitFramebuffer(0, 0, resources.viewport.width, resources.viewport.height, 0, 0, + resources.viewport.width, resources.viewport.height, + gl::COLOR_BUFFER_BIT, gl::NEAREST); + bind_engine_framebuffer(); + fn.glViewport(0, 0, resources.viewport.width, resources.viewport.height); + // Without the view transform, fall back to screen pixels as "world". + const bool viewKnown = g_view.width > 0.0f && g_view.height > 0.0f; + const auto& shimmer = resources.shimmer; + const int mask = levels > 1 ? 1 : 0; + fn.glUseProgram(shimmer.id); + fn.glUniform2f(shimmer.worldOrigin, viewKnown ? g_view.scrollX : 0.0f, + viewKnown ? g_view.scrollY : 0.0f); + fn.glUniform2f(shimmer.worldSize, + viewKnown ? g_view.width : static_cast(resources.viewport.width), + viewKnown ? g_view.height : static_cast(resources.viewport.height)); + fn.glUniform1f(shimmer.strength, g_settings.heatShimmer); + fn.glUniform1f(shimmer.time, animation_seconds()); + fn.glActiveTexture(gl::TEXTURE0 + 1); + fn.glBindTexture(gl::TEXTURE_2D, + resources.bloomChain.levels[static_cast(mask)].texture); + fn.glActiveTexture(gl::TEXTURE0); + fn.glBindTexture(gl::TEXTURE_2D, resources.sceneCopy.texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + } + + bind_engine_framebuffer(); + fn.glViewport(0, 0, resources.viewport.width, resources.viewport.height); + if (levels > 0) { + // Each level adds its share on the way up; normalise so the strengths + // mean the same thing at every resolution. + const float perLevel = 1.0f / static_cast(levels); + const unsigned glow = resources.bloomChain.levels[0].texture; + fn.glEnable(gl::BLEND); + if (g_settings.lightSpill > 0.0f) { + // framebuffer += framebuffer * light: surfaces near a light source get + // brighter and take its colour; black stays black. The light comes + // from a deep level of the chain, i.e. only the wide blurs, so its + // reach does not depend on how sharp the source sprite is. + const int wide = levels > 3 ? 2 : (levels > 1 ? 1 : 0); + fn.glBlendFunc(gl::DST_COLOR, gl::ONE); + draw_composite(resources.bloomChain.levels[static_cast(wide)].texture, + g_settings.lightSpill * 3.0f * perLevel, 0.0f, true); + } + if (g_settings.bloomStrength > 0.0f) { + fn.glBlendFunc(gl::ONE, gl::ONE); + draw_composite(glow, g_settings.bloomStrength * perLevel, 0.0f); + } + fn.glDisable(gl::BLEND); + } + + if (!resources.bloomDrawnOnce) { + resources.bloomDrawnOnce = true; + if (!gl::check_error("world post bloom")) { + fail("the bloom pass raised a GL error"); + } else { + try { + LOG_INFO("World post: first bloom drawn ({} levels)", levels); + } catch (...) { + } + } + } +} +} // namespace + +void world_post_configure(const core::EngineConfig& cfg) noexcept { + g_settings = {cfg.softFogOfWar, cfg.bloom, cfg.softFogRadius, cfg.bloomStrength, + cfg.lightSpill, cfg.softFogDrift, cfg.softFogSmoothing, cfg.heatShimmer, + cfg.spriteUpscale, cfg.spriteSharpness}; +} + +bool world_post_active() noexcept { + // Sprite upscaling needs the world flushed before the UI is queued, which + // is what the fog detour does when this is true. + return !g_failed && (g_settings.softFog || g_settings.bloom || g_settings.spriteUpscale); +} + +void world_post_poll_hotkeys() noexcept { + static bool fogKeyWasDown = false; + static bool bloomKeyWasDown = false; + static bool spriteKeyWasDown = false; + const bool fogKeyDown = (GetAsyncKeyState(VK_F8) & 0x8000) != 0; + const bool bloomKeyDown = (GetAsyncKeyState(VK_F9) & 0x8000) != 0; + const bool spriteKeyDown = (GetAsyncKeyState(VK_F7) & 0x8000) != 0; + const bool fogPressed = fogKeyDown && !fogKeyWasDown; + const bool bloomPressed = bloomKeyDown && !bloomKeyWasDown; + const bool spritePressed = spriteKeyDown && !spriteKeyWasDown; + fogKeyWasDown = fogKeyDown; + bloomKeyWasDown = bloomKeyDown; + spriteKeyWasDown = spriteKeyDown; + if (!fogPressed && !bloomPressed && !spritePressed) return; + if (fogPressed) g_settings.softFog = !g_settings.softFog; + if (bloomPressed) g_settings.bloom = !g_settings.bloom; + if (spritePressed) g_settings.spriteUpscale = !g_settings.spriteUpscale; + try { + LOG_INFO("Hotkey: soft fog of war={}, bloom={}, sprite upscale={}", g_settings.softFog, + g_settings.bloom, g_settings.spriteUpscale); + } catch (...) { + } +} + +void world_post_on_frame() noexcept { + ++g_frame; + g_worldPass.on_frame(); + ++g_frameTicksSinceArea; +} + +bool world_post_wants_atlas_upscale() noexcept { + return g_worldPass.open() && g_settings.spriteUpscale && !g_failed; +} + +unsigned world_post_upscale_atlas(int slot, unsigned sourceTexture, int width, int height, + int rows) noexcept { + const auto& fn = gl::get_gl_functions(); + if (!fn.postProcessAvailable || slot < 0 || slot >= 2 || !sourceTexture || width <= 0 || + height <= 0 || width > 4096 || height > 4096 || rows <= 0) { + return 0; + } + int drawFramebuffer = 0; + int readFramebuffer = 0; + fn.glGetIntegerv(gl::DRAW_FRAMEBUFFER_BINDING, &drawFramebuffer); + fn.glGetIntegerv(gl::READ_FRAMEBUFFER_BINDING, &readFramebuffer); + unsigned result = 0; + { + core::GlStateGuard textures({0}); + core::GlPassGuard pass; + const game::Extent doubled{width * 2, height * 2}; + bool ready = ensure_shared_resources(); + if (ready) { + auto& atlas = g_resources.atlases[static_cast(slot)]; + if (!(atlas.sharpened.extent == doubled)) { + gl::discard_errors(); + ready = create_target(atlas.upscaled, doubled, gl::RGBA8, gl::UNSIGNED_BYTE) && + create_target(atlas.sharpened, doubled, gl::RGBA8, gl::UNSIGNED_BYTE) && + gl::check_error("sprite atlas targets"); + } + if (ready) { + // Both shaders address texels by gl_FragCoord, so a viewport selects + // which part of the copy is redrawn. + const auto filter = [&](game::Region region) { + fn.glViewport(region.x, region.y, region.width, region.height); + fn.glBindFramebuffer(gl::FRAMEBUFFER, atlas.upscaled.framebuffer); + fn.glUseProgram(g_resources.spriteUpscale); + fn.glBindTexture(gl::TEXTURE_2D, sourceTexture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + fn.glBindFramebuffer(gl::FRAMEBUFFER, atlas.sharpened.framebuffer); + fn.glUseProgram(g_resources.spriteSharpen.id); + fn.glUniform1f(g_resources.spriteSharpen.sharpness, g_settings.spriteSharpness); + fn.glBindTexture(gl::TEXTURE_2D, atlas.upscaled.texture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + }; + // Only the rows the engine just filled; two rows of margin for the kernel. + const int outputRows = rows * 2 + 4 < doubled.height ? rows * 2 + 4 : doubled.height; + set_pass_state(); + filter({0, 0, doubled.width, outputRows}); + // Untextured shapes drawn in this flush sample the last corner of + // atlas 0, which those rows rarely reach: without it they come out + // transparent. + if (slot == 0 && outputRows < doubled.height) filter(game::untextured_corner(doubled)); + result = atlas.sharpened.texture; + ++g_atlasUpscalesSinceArea; + auto& drawnOnce = g_resources.atlasDrawnOnce[static_cast(slot)]; + if (!drawnOnce) { + drawnOnce = true; + if (!gl::check_error("sprite atlas upscale")) { + result = 0; + ready = false; + } else { + try { + LOG_INFO("World post: sprite atlas {} first upscaled ({}x{} -> {}x{}, {} rows)", slot, + width, height, doubled.width, doubled.height, rows); + } catch (...) { + } + } + } + } + } + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, static_cast(drawFramebuffer)); + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, static_cast(readFramebuffer)); + if (!ready) fail("the sprite atlas upscale could not run"); + } + return result; +} + +bool world_post_wants_emissive() noexcept { + return g_worldPass.open() && g_settings.bloom && !g_failed; +} + +bool world_post_begin_emissive() noexcept { + const auto& fn = gl::get_gl_functions(); + if (!fn.postProcessAvailable) return false; + fn.glGetIntegerv(gl::DRAW_FRAMEBUFFER_BINDING, &g_savedDrawFramebuffer); + fn.glGetIntegerv(gl::READ_FRAMEBUFFER_BINDING, &g_savedReadFramebuffer); + int viewport[4]{}; + fn.glGetIntegerv(gl::VIEWPORT, viewport); + if (viewport[0] != 0 || viewport[1] != 0 || viewport[2] <= 0 || viewport[3] <= 0) return false; + const game::Extent extent{viewport[2], viewport[3]}; + const bool firstThisFrame = g_emissiveFrame != g_frame; + if (firstThisFrame || !(extent == g_resources.viewport) || + gl::current_context() != g_resources.context) { + core::GlStateGuard textures({0}); + core::GlPassGuard pass; + const bool ready = ensure_resources(extent); + if (ready) { + fn.glBindFramebuffer(gl::FRAMEBUFFER, g_resources.emissive.framebuffer); + fn.glDisable(gl::SCISSOR_TEST); + fn.glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + fn.glClear(gl::COLOR_BUFFER_BIT); + g_emissiveCommands = 0; + } + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, static_cast(g_savedDrawFramebuffer)); + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, static_cast(g_savedReadFramebuffer)); + if (!ready) { + fail("GL resources could not be created"); + return false; + } + g_emissiveFrame = g_frame; + } + // State is the engine's again; only the framebuffer differs for the replay. + fn.glBindFramebuffer(gl::FRAMEBUFFER, g_resources.emissive.framebuffer); + return true; +} + +void world_post_end_emissive(int commands) noexcept { + const auto& fn = gl::get_gl_functions(); + g_emissiveCommands += commands; + fn.glBindFramebuffer(gl::DRAW_FRAMEBUFFER, static_cast(g_savedDrawFramebuffer)); + fn.glBindFramebuffer(gl::READ_FRAMEBUFFER, static_cast(g_savedReadFramebuffer)); +} + +bool world_post_before_fog(const WorldView& view) noexcept { + g_view = view; + const auto& fn = gl::get_gl_functions(); + g_worldPass.on_area_drawn(); + if (!fn.postProcessAvailable) { + fail("the GL context lacks framebuffer or vertex-array support"); + return false; + } + fn.glGetIntegerv(gl::DRAW_FRAMEBUFFER_BINDING, &g_engineDrawFramebuffer); + fn.glGetIntegerv(gl::READ_FRAMEBUFFER_BINDING, &g_engineReadFramebuffer); + + bool capturing = false; + { + core::GlStateGuard textures({0, 1}); + core::GlPassGuard pass; + const int* viewport = pass.viewport(); + if (viewport[0] != 0 || viewport[1] != 0 || viewport[2] <= 0 || viewport[3] <= 0) { + fail("the engine viewport is not a full-window rectangle at the origin"); + } else if (!ensure_resources({viewport[2], viewport[3]})) { + fail("GL resources could not be created"); + } else { + g_pixelsPerWorldPixel = + game::pixels_per_world_pixel(static_cast(viewport[2]), view.width); + set_pass_state(); + if (g_settings.bloom) { + draw_bloom(); + set_pass_state(); + } + if (g_settings.softFog && !g_failed) { + // White = "nothing hidden". The engine's alpha-blended black fog then + // leaves, per pixel, the fraction of the scene that stays visible. + fn.glBindFramebuffer(gl::FRAMEBUFFER, g_resources.fogCapture.framebuffer); + fn.glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + fn.glClear(gl::COLOR_BUFFER_BIT); + capturing = true; + } + if (g_logNextFrame.exchange(false, std::memory_order_relaxed)) { + const auto plan = game::blur_plan_for_radius( + g_settings.fogRadius * g_pixelsPerWorldPixel, g_resources.viewport); + try { + LOG_INFO( + "World post: softFog={} (radius {} world px, blur levels={} offset={:.2f}), " + "bloom={} (strength {}, light spill {}, {} additive draws this frame), sprite " + "upscale={} ({} atlas passes, {} frame boundaries since the last area frame), " + "viewport {}x{}, {:.3f} px per world px, engine framebuffer draw={} read={}", + g_settings.softFog, g_settings.fogRadius, plan.levels, plan.offset, + g_settings.bloom, g_settings.bloomStrength, g_settings.lightSpill, + g_emissiveFrame == g_frame ? g_emissiveCommands : 0, g_settings.spriteUpscale, + g_atlasUpscalesSinceArea, g_frameTicksSinceArea, viewport[2], viewport[3], + g_pixelsPerWorldPixel, g_engineDrawFramebuffer, g_engineReadFramebuffer); + } catch (...) { + } + } + } + bind_engine_framebuffer(); + } + g_frameTicksSinceArea = 0; + g_atlasUpscalesSinceArea = 0; + // The guards have restored the engine's state; hand it our target for the + // fog draw only. Framebuffer bindings are not part of the engine's cache. + if (capturing) fn.glBindFramebuffer(gl::FRAMEBUFFER, g_resources.fogCapture.framebuffer); + return capturing; +} + +void world_post_after_fog() noexcept { + const auto& fn = gl::get_gl_functions(); + auto& resources = g_resources; + core::GlStateGuard textures({0, 1}); + core::GlPassGuard pass; + set_pass_state(); + const auto plan = + game::blur_plan_for_radius(g_settings.fogRadius * g_pixelsPerWorldPixel, resources.viewport); + const bool blurred = run_blur(resources.fogCapture, resources.fogChain, plan, false) > 0; + unsigned fogTexture = resources.fogChain.levels[0].texture; + const bool viewKnown = g_view.width > 0.0f && g_view.height > 0.0f; + if (g_dropFogHistory.exchange(false, std::memory_order_relaxed)) { + resources.fogHistoryValid = false; + } + if (blurred && viewKnown && g_settings.fogSmoothing > 0.0f) { + const auto now = std::chrono::steady_clock::now(); + const float step = std::chrono::duration(now - resources.fogHistoryTime).count(); + const float blend = resources.fogHistoryValid + ? game::temporal_blend(step, g_settings.fogSmoothing) + : 1.0f; + const auto& shown = resources.fogHistory[static_cast(resources.fogHistoryCurrent)]; + const int next = 1 - resources.fogHistoryCurrent; + const auto& target = resources.fogHistory[static_cast(next)]; + const auto& temporal = resources.fogTemporal; + fn.glBindFramebuffer(gl::FRAMEBUFFER, target.framebuffer); + fn.glViewport(0, 0, target.extent.width, target.extent.height); + fn.glUseProgram(temporal.id); + fn.glUniform2f(temporal.origin, g_view.scrollX, g_view.scrollY); + fn.glUniform2f(temporal.size, g_view.width, g_view.height); + fn.glUniform2f(temporal.previousOrigin, resources.fogHistoryView.scrollX, + resources.fogHistoryView.scrollY); + // A zero size would divide by zero; with blend 1 the history is unused. + fn.glUniform2f(temporal.previousSize, + resources.fogHistoryValid ? resources.fogHistoryView.width : 1.0f, + resources.fogHistoryValid ? resources.fogHistoryView.height : 1.0f); + fn.glUniform1f(temporal.blend, blend); + fn.glActiveTexture(gl::TEXTURE0 + 1); + fn.glBindTexture(gl::TEXTURE_2D, shown.texture); + fn.glActiveTexture(gl::TEXTURE0); + fn.glBindTexture(gl::TEXTURE_2D, fogTexture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + resources.fogHistoryCurrent = next; + resources.fogHistoryValid = true; + resources.fogHistoryView = g_view; + resources.fogHistoryTime = now; + fogTexture = target.texture; + } else { + resources.fogHistoryValid = false; + } + bind_engine_framebuffer(); + fn.glViewport(0, 0, resources.viewport.width, resources.viewport.height); + // framebuffer = framebuffer * visible fraction + fn.glEnable(gl::BLEND); + fn.glBlendFunc(gl::DST_COLOR, gl::ZERO); + if (blurred) { + fn.glUseProgram(resources.fog.id); + fn.glUniform2f(resources.fog.worldOrigin, g_view.scrollX, g_view.scrollY); + fn.glUniform2f(resources.fog.worldSize, g_view.width, g_view.height); + fn.glUniform1f(resources.fog.drift, g_settings.fogDrift); + fn.glUniform1f(resources.fog.time, animation_seconds()); + fn.glBindTexture(gl::TEXTURE_2D, fogTexture); + fn.glDrawArrays(gl::TRIANGLES, 0, 3); + } else { + // Unblurred capture: must reproduce the engine's fog exactly. + draw_composite(resources.fogCapture.texture, 1.0f, 0.0f); + } + if (!resources.fogDrawnOnce) { + resources.fogDrawnOnce = true; + if (!gl::check_error("world post fog composite")) { + fail("the fog composite raised a GL error"); + } else { + try { + LOG_INFO("World post: first fog composite drawn (blurred={})", blurred); + } catch (...) { + } + } + } +} + +void world_post_on_area_load() noexcept { + g_logNextFrame.store(true, std::memory_order_relaxed); + g_dropFogHistory.store(true, std::memory_order_relaxed); +} + +void world_post_forget() noexcept { g_resources = {}; } +} // namespace iee::features diff --git a/src/iee/features/world_post.h b/src/iee/features/world_post.h new file mode 100644 index 0000000..e17b5a4 --- /dev/null +++ b/src/iee/features/world_post.h @@ -0,0 +1,66 @@ +#pragma once + +namespace iee::core { +struct EngineConfig; +} + +namespace iee::features { +// The part of the world the view shows, in world pixels. All zero when unknown. +struct WorldView { + float scrollX{}; + float scrollY{}; + float width{}; + float height{}; +}; + +// World post passes: soft fog of war, and glow plus light spill from the +// engine's light-emitting draws, run from the +// CInfinity::RenderFog detour. The engine queues the whole frame and only +// draws in DrawFlush_GL, so the caller flushes that queue around these calls: +// +// flush(); // world is now in the framebuffer +// bool capturing = world_post_before_fog(); // glow + light spill; fog target bound if true +// original RenderFog // queues the fog triangles +// if (capturing) { flush(); world_post_after_fog(); } +// +// Render thread only, except world_post_on_area_load. + +void world_post_configure(const core::EngineConfig& cfg) noexcept; +// True when at least one effect is switched on and no pass has failed. +bool world_post_active() noexcept; +// Debug A/B: F7 toggles sprite upscaling, F8 soft fog of war, F9 bloom. Call once per +// frame, only when debug hotkeys are enabled. +void world_post_poll_hotkeys() noexcept; +// Frame boundary: the next world pass starts. +void world_post_on_frame() noexcept; +// Bloom is fed by the engine's own additive draws (fires, spell effects, +// glows). The DrawFlush_GL detour replays them into a black target: +// +// if (world_post_wants_emissive()) { +// collect the queue's additive commands; original flush; +// if (any && world_post_begin_emissive()) { // our target is bound +// put them back in the queue; original flush; +// world_post_end_emissive(count); // engine framebuffer rebound +// } +// } +bool world_post_wants_emissive() noexcept; +bool world_post_begin_emissive() noexcept; +void world_post_end_emissive(int commands) noexcept; +// Sprite smoothing. The engine composites sprites on the CPU into a streaming +// atlas and uploads it at the start of a flush. Right after that upload, in +// the world pass, the atlas is upscaled 2x with FSR1 (edge-adaptive upsample, +// then sharpen); the caller makes the engine draw from the returned texture +// for the rest of that flush and puts the engine's own back afterwards. +// Normalised texture coordinates are unchanged by the larger texture. +bool world_post_wants_atlas_upscale() noexcept; +// Returns the GL name of the upscaled texture, or 0 to leave the atlas alone. +// `rows` is how many atlas rows the engine just uploaded. +unsigned world_post_upscale_atlas(int slot, unsigned sourceTexture, int width, int height, + int rows) noexcept; +bool world_post_before_fog(const WorldView& view) noexcept; +void world_post_after_fog() noexcept; +// Any thread. The next frame logs what engaged. +void world_post_on_area_load() noexcept; +// Drops every GL handle without touching GL. For shutdown paths. +void world_post_forget() noexcept; +} // namespace iee::features diff --git a/src/iee/frame_hook.cpp b/src/iee/frame_hook.cpp index 22a221a..775bfd9 100644 --- a/src/iee/frame_hook.cpp +++ b/src/iee/frame_hook.cpp @@ -9,6 +9,7 @@ #include "iee/core/logger.h" #include "iee/core/pattern_scanner.h" #include "iee/core/performance_samples.h" +#include "iee/features/world_post.h" #include "iee/hooks.h" #include "iee/shader_probe.h" @@ -64,6 +65,7 @@ void frame_tick() { g_frames.fetch_add(1, std::memory_order_relaxed); hooks::retry_shader_probe_install(); probe::on_frame_tick(seconds_since_install()); + features::world_post_on_frame(); } void detour_sdl_swap(void* window) { diff --git a/src/iee/game/animation_interp.cpp b/src/iee/game/animation_interp.cpp new file mode 100644 index 0000000..b8550a3 --- /dev/null +++ b/src/iee/game/animation_interp.cpp @@ -0,0 +1,284 @@ +#include "animation_interp.h" + +#include +#include +#include +#include +#include +#include + +#include "iee/core/logger.h" +#include "iee/core/pattern_scanner.h" + +namespace iee::game { +namespace { +constexpr std::uint32_t kBamSignature = 0x204D4142; // "BAM " +constexpr std::uint32_t kBamVersion1 = 0x20203156; // "V1 " +constexpr std::uint32_t kRawFrameFlag = 0x80000000u; +constexpr std::uint16_t kBamNoFrame = 0xFFFF; +constexpr std::size_t kPaletteBytes = 256 * 4; +constexpr std::size_t kMaxExpandedBamBytes = 64u * 1024u * 1024u; +constexpr std::size_t kMaxSwapsPerScope = 32; + +// True when [offset, offset + count * stride) lies inside `size`. +bool in_bounds(std::size_t offset, std::size_t count, std::size_t stride, + std::size_t size) noexcept { + return offset <= size && count <= (size - offset) / stride; +} +} // namespace + +bool parse_expanded_bam(std::span image, ExpandedBamView& out) noexcept { + out = {}; + if (image.size() < sizeof(bamHeader_st)) return false; + bamHeader_st header{}; + std::memcpy(&header, image.data(), sizeof(header)); + if (header.nFileType != kBamSignature || header.nFileVersion != kBamVersion1 || + header.nFrames == 0 || header.nSequences == 0) { + return false; + } + const std::size_t size = image.size(); + const std::size_t framesOffset = header.nTableOffset; + if (!in_bounds(framesOffset, header.nFrames, sizeof(frameTableEntry_st), size)) return false; + const std::size_t sequencesOffset = framesOffset + header.nFrames * sizeof(frameTableEntry_st); + if (!in_bounds(sequencesOffset, header.nSequences, sizeof(sequenceTableEntry_st), size) || + !in_bounds(header.nPaletteOffset, kPaletteBytes, 1, size) || + header.nFrameListOffset > size) { + return false; + } + const std::size_t listEntries = (size - header.nFrameListOffset) / sizeof(std::uint16_t); + + for (std::size_t index = 0; index < header.nFrames; ++index) { + frameTableEntry_st frame{}; + std::memcpy(&frame, image.data() + framesOffset + index * sizeof(frame), sizeof(frame)); + if ((frame.___u4 & kRawFrameFlag) == 0) return false; + const std::size_t pixels = static_cast(frame.nWidth) * frame.nHeight; + if (!in_bounds(frame.___u4 & ~kRawFrameFlag, pixels, 1, size)) return false; + } + for (std::size_t index = 0; index < header.nSequences; ++index) { + sequenceTableEntry_st sequence{}; + std::memcpy(&sequence, image.data() + sequencesOffset + index * sizeof(sequence), + sizeof(sequence)); + if (sequence.nFrames < 0) return false; + const auto count = static_cast(sequence.nFrames); + if (count == 0) continue; + if (sequence.nStartingFrame > listEntries || count > listEntries - sequence.nStartingFrame) { + return false; + } + for (std::size_t slot = 0; slot < count; ++slot) { + std::uint16_t listed = 0; + std::memcpy(&listed, + image.data() + header.nFrameListOffset + + (sequence.nStartingFrame + slot) * sizeof(listed), + sizeof(listed)); + // 0xFFFF is the format's "no frame in this slot"; the engine skips it. + if (listed >= header.nFrames && listed != kBamNoFrame) return false; + } + } + + auto* base = image.data(); + out.header = reinterpret_cast(base); + out.frames = reinterpret_cast(base + framesOffset); + out.sequences = reinterpret_cast(base + sequencesOffset); + out.frameList = reinterpret_cast(base + header.nFrameListOffset); + out.palette = base + header.nPaletteOffset; + return true; +} + +std::uint16_t AnimationFrameTracker::sample(const void* key, std::uint16_t sequence, + std::int16_t frame, std::int16_t length, double now) { + auto [it, inserted] = states_.try_emplace(key); + auto& state = it->second; + const auto original = static_cast(frame * 2); + if (inserted || now - state.lastSeen > kForgetSeconds || sequence != state.sequence) { + state = {sequence, frame, -1, now, kDefaultInterval, now}; + if (inserted && states_.size() > kPruneThreshold) { + for (auto entry = states_.begin(); entry != states_.end();) { + entry = now - entry->second.lastSeen > kForgetSeconds ? states_.erase(entry) + : std::next(entry); + } + } + return original; + } + state.lastSeen = now; + if (frame != state.frame) { + const double observed = now - state.changedAt; + state.interval = + observed >= kMinInterval && observed <= kMaxInterval ? observed : kDefaultInterval; + state.previous = state.frame; + state.frame = frame; + state.changedAt = now; + } + const bool consecutive = + state.previous >= 0 && length > 1 && frame == (state.previous + 1) % length; + if (consecutive && now - state.changedAt < state.interval / 2.0) { + return static_cast(state.previous * 2 + 1); + } + return original; +} + +std::shared_ptr AnimationInterpolator::load( + const std::string& resref) { + if (const auto known = files_.find(resref); known != files_.end()) return known->second; + std::shared_ptr result; + std::error_code error; + const auto path = directory_ / (resref + ".bam"); + const auto size = std::filesystem::file_size(path, error); + if (!error && size >= sizeof(bamHeader_st) && size <= kMaxExpandedBamBytes) { + auto expanded = std::make_shared(); + expanded->image.resize(static_cast(size)); + std::ifstream file(path, std::ios::binary); + if (file.read(reinterpret_cast(expanded->image.data()), + static_cast(size)) && + parse_expanded_bam(expanded->image, expanded->view)) { + result = std::move(expanded); + ++loaded_; + LOG_INFO("Animation interpolation: loaded expanded BAM {} ({} frames)", resref, + result->view.header->nFrames); + } else { + LOG_WARN("Animation interpolation: {} is not a valid expanded BAM; ignored", + path.string()); + } + } else if (!error) { + LOG_WARN("Animation interpolation: {} has an unusable size; ignored", path.string()); + } else { + LOG_INFO("Animation interpolation: no expanded BAM for {}", resref); + } + files_.emplace(resref, result); + return result; +} + +AnimationInterpolator::Resource* AnimationInterpolator::resource_for(CResCell* original) { + if (const auto known = resources_.find(original); known != resources_.end()) { + return known->second.get(); + } + CResCell snapshot{}; + if (!core::safe_read(original, snapshot)) return nullptr; + // The original must be a loaded BAM V1: its tables are the logic's view and + // the template the private cell is copied from. Not cached until it is. + if (!snapshot.baseclass_0.bLoaded || !snapshot.baseclass_0.pData || !snapshot.m_pBamHeader || + snapshot.m_pBamHeaderV2 || !snapshot.m_pSequences || !snapshot.baseclass_0.resref) { + return nullptr; + } + // The name is an interned C string of at most 8 characters; read it one + // byte at a time so nothing past its terminator is touched. + std::array name{}; + for (std::size_t index = 0; index < 8; ++index) { + if (!core::safe_read(snapshot.baseclass_0.resref + index, name[index]) || !name[index]) { + name[index] = '\0'; + break; + } + } + std::string resref(name.data()); + for (auto& character : resref) { + character = static_cast(std::toupper(static_cast(character))); + } + + auto resource = std::make_unique(); + if (!resref.empty() && resref.find_first_of("/\\.:") == std::string::npos) { + resource->expanded = load(resref); + } + if (resource->expanded) { + const auto& view = resource->expanded->view; + bamHeader_st originalHeader{}; + if (!core::safe_read(snapshot.m_pBamHeader, originalHeader) || + originalHeader.nSequences != view.header->nSequences) { + LOG_WARN("Animation interpolation: expanded BAM {} does not match the game's cycle count; " + "ignored", + resref); + resource->expanded.reset(); + } else { + resource->cell = snapshot; + resource->cell.baseclass_0.pData = resource->expanded->image.data(); + resource->cell.baseclass_0.nSize = static_cast(resource->expanded->image.size()); + resource->cell.baseclass_0.bWasMalloced = false; + resource->cell.baseclass_0.bLoaded = true; + resource->cell.pUncompressedData = nullptr; + resource->cell.nUncompressedSize = 0; + resource->cell.m_pBamHeader = view.header; + resource->cell.m_pBamHeaderV2 = nullptr; + resource->cell.m_pQuads = nullptr; + resource->cell.m_pFrames = view.frames; + resource->cell.m_pSequences = view.sequences; + resource->cell.m_pFrameList = view.frameList; + resource->cell.m_pPalette = view.palette; + } + } + return resources_.emplace(original, std::move(resource)).first->second.get(); +} + +void AnimationInterpolator::begin_scope(const void* owner) noexcept { + if (depth_++ > 0) return; + owner_ = owner; + scopeTouched_ = 0; + scopeMissed_ = false; + observeOnly_ = true; + try { + if (const auto known = owners_.find(owner); known != owners_.end()) { + observeOnly_ = !known->second; + } + } catch (...) { + } +} + +void AnimationInterpolator::touch(CVidCell* cell, double now) noexcept { + if (depth_ <= 0 || !cell) return; + try { + for (const auto& swap : swaps_) { + if (swap.cell == cell) return; + } + if (!cell->pRes) return; + ++scopeTouched_; + auto* original = cell->pRes; + auto* resource = resource_for(original); + if (!resource || !resource->expanded) { + scopeMissed_ = true; + return; + } + const auto sequence = cell->m_nCurrentSequence; + const auto frame = cell->m_nCurrentFrame; + const auto& view = resource->expanded->view; + sequenceTableEntry_st logic{}; + if (sequence >= view.header->nSequences || + !core::safe_read(original->m_pSequences + sequence, logic) || logic.nFrames <= 0 || + frame < 0 || frame >= logic.nFrames || + view.sequences[sequence].nFrames != logic.nFrames * 2) { + scopeMissed_ = true; + return; + } + if (observeOnly_ || swaps_.size() >= kMaxSwapsPerScope) return; + const auto shown = tracker_.sample(cell, sequence, frame, logic.nFrames, now); + swaps_.push_back({cell, original, frame}); + cell->pRes = &resource->cell; + cell->m_nCurrentFrame = static_cast(shown); + cell->m_pFrame = nullptr; + } catch (...) { + // Interpolation is cosmetic; any doubt draws the original frame. + scopeMissed_ = true; + } +} + +void AnimationInterpolator::end_scope() noexcept { + if (depth_ <= 0) return; + if (--depth_ > 0) return; + for (const auto& swap : swaps_) { + // Restore only our own swap: if the engine re-pointed the cell during the + // draw, its resource is the newer one and must win. + bool ours = false; + for (const auto& [original, resource] : resources_) { + if (swap.cell->pRes == &resource->cell) { + ours = true; + break; + } + } + if (!ours) continue; + swap.cell->pRes = swap.original; + swap.cell->m_nCurrentFrame = swap.frame; + swap.cell->m_pFrame = nullptr; + } + swaps_.clear(); + try { + owners_[owner_] = scopeTouched_ > 0 && !scopeMissed_; + } catch (...) { + } +} +} // namespace iee::game diff --git a/src/iee/game/animation_interp.h b/src/iee/game/animation_interp.h new file mode 100644 index 0000000..df4e43e --- /dev/null +++ b/src/iee/game/animation_interp.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "runtime_types_x64.h" + +namespace iee::game { +// Table pointers into a validated expanded BAM V1 image, the same four that +// CResCell::Parse derives. +struct ExpandedBamView { + bamHeader_st* header{}; + frameTableEntry_st* frames{}; + sequenceTableEntry_st* sequences{}; + std::uint16_t* frameList{}; + void* palette{}; +}; + +// Validates an uncompressed BAM V1 image end to end (tables, lookup entries +// and every frame's raw pixel block inside the image) before the engine is +// ever pointed at it. Only raw frames are accepted: an RLE frame's length +// cannot be bounded without decoding it. +[[nodiscard]] bool parse_expanded_bam(std::span image, ExpandedBamView& out) noexcept; + +// Chooses which expanded frame to draw for a cell whose logic frame only +// changes on the engine's animation tick. An expanded cycle holds original +// frame k at 2k and the in-between leading to k+1 at 2k+1; for the first half +// of the observed tick interval after a step k-1 -> k the in-between is shown, +// then frame k. The picture therefore trails the logic by half a step and +// never guesses a frame the engine has not reached. +class AnimationFrameTracker { + public: + static constexpr double kDefaultInterval = 1.0 / 15.0; + static constexpr double kMinInterval = 1.0 / 90.0; + static constexpr double kMaxInterval = 1.0 / 4.0; + static constexpr double kForgetSeconds = 0.5; + static constexpr std::size_t kPruneThreshold = 4096; + + // `frame` is the logic frame within a cycle of `length` original frames. + [[nodiscard]] std::uint16_t sample(const void* key, std::uint16_t sequence, std::int16_t frame, + std::int16_t length, double now); + void clear() noexcept { states_.clear(); } + + private: + struct State { + std::uint16_t sequence{}; + std::int16_t frame{}; + std::int16_t previous{-1}; + double changedAt{}; + double interval{kDefaultInterval}; + double lastSeen{}; + }; + std::unordered_map states_; +}; + +// Draw-time substitution of expanded (frame-interpolated) BAMs. +// +// Game logic keeps the original BAM and frame number. While a creature is +// being drawn (a scope), each of its CVidCells is pointed at a private +// CResCell over the expanded image and at the in-between frame for the +// current instant; closing the scope puts the original resource and logic +// frame back. Expanded files are read from `directory/.bam` on first +// use; a BAM without one draws exactly as before. +// +// A creature is drawn from several frame-synced cells (body, weapon, shield, +// helmet), each its own BAM. Swapping only some of them would draw the body +// half a step behind its weapon, so a creature is interpolated only when +// every cell it drew in its previous scope had an expanded BAM: each owner's +// first scope (and any scope after a miss) only observes. +// +// Single-threaded: every call must come from the engine's render thread. +class AnimationInterpolator { + public: + void set_directory(std::filesystem::path directory) { directory_ = std::move(directory); } + + // `owner` identifies the creature being drawn. + void begin_scope(const void* owner) noexcept; + // Restores every cell swapped since the matching begin_scope and records + // whether the owner's cells were all covered. + void end_scope() noexcept; + // Inside a scope: swap `cell` if it is not swapped yet and an expanded BAM + // exists for its resource. Outside a scope, or on any doubt, does nothing. + void touch(CVidCell* cell, double now) noexcept; + // Area change: forget per-cell animation timing (loaded files are kept). + void reset_timing() noexcept { + tracker_.clear(); + owners_.clear(); + } + + [[nodiscard]] std::size_t loaded_count() const noexcept { return loaded_; } + + private: + struct Expanded { + std::vector image; + ExpandedBamView view; + }; + struct Resource { + std::shared_ptr expanded; // null: no usable expanded BAM + CResCell cell{}; // private copy pointing into `expanded` + }; + struct Swap { + CVidCell* cell{}; + CResCell* original{}; + std::int16_t frame{}; + }; + + Resource* resource_for(CResCell* original); + std::shared_ptr load(const std::string& resref); + + std::filesystem::path directory_; + int depth_{}; + const void* owner_{}; + bool observeOnly_{true}; + bool scopeMissed_{}; + std::size_t scopeTouched_{}; + std::unordered_map owners_; // owner -> last scope fully covered + std::vector swaps_; + std::unordered_map> resources_; + std::unordered_map> files_; + AnimationFrameTracker tracker_; + std::size_t loaded_{}; +}; +} // namespace iee::game diff --git a/src/iee/game/are_animations.cpp b/src/iee/game/are_animations.cpp new file mode 100644 index 0000000..7e3ff76 --- /dev/null +++ b/src/iee/game/are_animations.cpp @@ -0,0 +1,429 @@ +#include "are_animations.h" + +#include +#include +#include +#include +#include +#include + +#include "file_formats.h" + +namespace iee::game { +namespace { +constexpr std::uint32_t kAreFileType = 0x41455241; // "AREA" +constexpr std::uint32_t kAreFileVersion = 0x302E3156; // "V1.0" + +template +bool read_struct(const std::byte* base, std::size_t size, std::size_t offset, T& out) noexcept { + if (offset > size || size - offset < sizeof(T)) { + return false; + } + + std::memcpy(&out, base + offset, sizeof(T)); + return true; +} + +ResrefBuffer copy_resref(const std::array& raw) noexcept { + ResrefBuffer out{}; + out.fill('\0'); + for (std::size_t i = 0; i < 8; ++i) { + const auto c = static_cast(raw[i]); + if (c == '\0') break; + out[i] = c; + } + return out; +} + +std::string upper_copy(std::string_view value) { + std::string result(value); + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + return result; +} + +bool starts_with_any(std::string_view value, + std::initializer_list prefixes) noexcept { + for (const auto prefix : prefixes) { + if (value.starts_with(prefix)) return true; + } + return false; +} + +bool contains_any(std::string_view value, std::initializer_list needles) { + for (const auto needle : needles) { + if (value.find(needle) != std::string_view::npos) return true; + } + return false; +} +} // namespace + +std::string_view area_animation_kind_name(AreaAnimationKind kind) noexcept { + switch (kind) { + case AreaAnimationKind::Fire: + return "fire"; + case AreaAnimationKind::Smoke: + return "smoke"; + case AreaAnimationKind::Fountain: + return "fountain"; + case AreaAnimationKind::Light: + return "light"; + case AreaAnimationKind::Water: + return "water"; + case AreaAnimationKind::Lava: + return "lava"; + case AreaAnimationKind::Wildlife: + return "wildlife"; + case AreaAnimationKind::None: + default: + return "none"; + } +} + +namespace { +// Per-area overlays whose kind was established by visual frame inspection of +// the exported BAMs (see docs/are-animation-detection.md). Exact matches win +// over every generic rule. +struct ExactResrefKind { + std::string_view resref; + AreaAnimationKind kind; +}; +constexpr ExactResrefKind kExactResrefKinds[] = { + {"AM003XA", AreaAnimationKind::Fire}, // lit hearth overlay (many BG2 city areas) + {"AM4000Z", AreaAnimationKind::Fire}, // flame columns (AR4000 heads) + {"AM5204D", AreaAnimationKind::Fire}, // bonfire + {"AM5508D", AreaAnimationKind::Fire}, // ember glow arc + {"AM5508C", AreaAnimationKind::Light}, // glow orb + {"AM0202FL", AreaAnimationKind::Light}, // star glint/flare + {"AM6004A", AreaAnimationKind::Smoke}, // dark smoke plume + {"AM6004B", AreaAnimationKind::Smoke}, // dark smoke plume + {"AM0604A", AreaAnimationKind::Fountain}, // tiered fountain +}; +} // namespace + +// Table grown from the full BG1EE + BG2EE ARE exports plus visual frame +// inspection of the ambiguous BAMs. Candle-named flames are deliberately +// Light (dim glow), bare FLM*/FLSM* flames are Fire. Note: ARW[DN]* +// overlays are day/night SHADOW scenery, not lit windows — verified from +// frames; they intentionally stay None. +AreaAnimationKind classify_area_animation(std::string_view resref, + std::string_view name) noexcept { + if (resref.empty() && name.empty()) return AreaAnimationKind::None; + + const auto upperResref = upper_copy(resref); + const auto upperName = upper_copy(name); + + for (const auto& exact : kExactResrefKinds) { + if (upperResref == exact.resref) return exact.kind; + } + + if (upperName.find("CANDLE") != std::string_view::npos) { + return AreaAnimationKind::Light; + } + // "FPIT" (fire pit) confirmed against BG2EE AR0406; FLM*/FLSM* are the + // BG1EE small/medium/large flame BAM families (sconces, wall flames); + // FIM* are yellow flames (OH areas) and YSFL* the blue flames of AR1009 — + // both verified from exported frames. + if (starts_with_any(upperResref, + {"FLAM", "FIRE", "TORCH", "BRAZ", "FPIT", "FLM", "FLSM", "FIM", "YSFL"}) || + contains_any(upperName, {"FIRE", "FLAME", "TORCH", "BRAZIER", "SCONCE"})) { + return AreaAnimationKind::Fire; + } + // Steam pipes (BG2EE AR3017 planar machinery, sewers) read as smoke. + if (starts_with_any(upperResref, {"SMOK", "CHIM", "STEAM", "AMSTEAM"}) || + contains_any(upperName, {"SMOKE", "CHIMNEY", "FOG", "MIST", "STEAM"})) { + return AreaAnimationKind::Smoke; + } + if (starts_with_any(upperResref, {"FOUNT", "FNTN"}) || + contains_any(upperName, {"FOUNTAIN"})) { + return AreaAnimationKind::Fountain; + } + if (contains_any(upperName, {"LAVA"})) { + return AreaAnimationKind::Lava; + } + if (starts_with_any(upperResref, {"SPLASH", "RIPPLE", "WTDBL", "BUBBLE"}) || + contains_any(upperName, {"WATERFALL", "WATER", "SPLASH", "RIPPLE", "LAKE", "RIVER", + "BUBBLE"})) { + return AreaAnimationKind::Water; + } + if (starts_with_any(upperResref, {"BUTRFLY", "FLIES", "FISH", "BIRD"}) || + contains_any(upperName, {"FISH", "FLIES", "BUTTERFLY", "BUTRFLY", "MAGGOT"})) { + return AreaAnimationKind::Wildlife; + } + // "LIGHTNING" is weather, not an authored light source. + const bool nameIsLightning = upperName.find("LIGHTNING") != std::string_view::npos; + if (starts_with_any(upperResref, {"GLOW", "CANDL", "LANT", "LAMP"}) || + (!nameIsLightning && contains_any(upperName, {"GLOW", "LANTERN", "LAMP", "LIGHT"}))) { + return AreaAnimationKind::Light; + } + return AreaAnimationKind::None; +} + +std::string_view AreaAnimationInfo::nameView() const noexcept { + std::size_t length = 0; + while (length < name.size() - 1 && name[length] != '\0') ++length; + return {name.data(), length}; +} + +bool AreaAnimationInfo::isShown() const noexcept { + return (flags & kAreAnimationFlagIsShown) != 0; +} + +bool AreaAnimationInfo::isMirrored() const noexcept { + return (flags & kAreAnimationFlagMirror) != 0; +} + +bool AreaAnimationInfo::isLightSource() const noexcept { + return (flags & kAreAnimationFlagNotLightSource) == 0; +} + +std::size_t AreaAnimationsInfo::count_of(AreaAnimationKind kind) const noexcept { + std::size_t count = 0; + for (const auto& animation : animations) { + if (animation.kind == kind) ++count; + } + return count; +} + +bool parse_are_animations(const std::byte* data, std::size_t size, + AreaAnimationsInfo& out) noexcept { + out = {}; + + if (!data || size < sizeof(ARE_Header_st)) { + return false; + } + + ARE_Header_st header{}; + if (!read_struct(data, size, 0, header)) { + return false; + } + if (header.nFileType != kAreFileType || header.nFileVersion != kAreFileVersion) { + return false; + } + + const auto count = static_cast(header.nAnimations); + if (count == 0) { + return true; + } + if (count > kMaxAreaAnimationRecords) { + return false; + } + + const auto sectionOffset = static_cast(header.nAnimationsOffset); + const auto sectionBytes = count * sizeof(ARE_Animation_st); + if (sectionOffset > size || sectionBytes > size - sectionOffset) { + return false; + } + + try { + out.animations.reserve(count); + } catch (...) { + out = {}; + return false; + } + + for (std::size_t i = 0; i < count; ++i) { + ARE_Animation_st record{}; + if (!read_struct(data, size, sectionOffset + i * sizeof(ARE_Animation_st), record)) { + out = {}; + return false; + } + + try { + out.animations.push_back(make_area_animation_info(record)); + } catch (...) { + out = {}; + return false; + } + } + + return true; +} + +namespace { +// Authored flame draw geometry from the game BAM frame tables. RenderBam +// draws the current frame's top-left at (CGameObject::m_pos - frameCenter), +// so relative to m_pos the flame's bottom-center sits at +// (w/2 - cx, h - cy) — dx/dy below. height/halfWidth are the authored +// footprint in world px. +struct FlameGeometry { + std::string_view resref; + float dx; + float dy; + float height; + float halfWidth; +}; +constexpr FlameGeometry kFlameGeometry[] = { + {"FLAMBLU2", 4.0f, 15.0f, 15.0f, 4.0f}, {"FLMS", 0.0f, 3.0f, 5.0f, 1.5f}, + {"FLMSW", 0.0f, 3.0f, 5.0f, 1.5f}, {"FLSM1W", 0.0f, 10.0f, 20.0f, 10.0f}, + {"FLSM2W", 0.0f, 20.0f, 40.0f, 20.0f}, {"FLSM1RED", 9.0f, 19.0f, 20.0f, 10.0f}, + {"FLM1RED1", 0.0f, 4.0f, 20.0f, 10.0f}, {"FLML", 1.0f, 11.0f, 21.0f, 5.0f}, + {"FLMM", 1.0f, 8.0f, 15.0f, 4.0f}, {"YSFLBLU2", 0.0f, 0.0f, 15.0f, 4.0f}, + {"FIM1YLN1", 0.0f, 5.0f, 25.0f, 12.0f}, {"FIM2YLN2", 0.0f, 8.0f, 50.0f, 25.0f}, + {"FIRE", -6.0f, 30.0f, 48.0f, 6.0f}, {"FLAME2S", 0.0f, 3.0f, 12.0f, 6.0f}, + {"FLAME2L", 0.0f, 10.0f, 29.0f, 6.0f}, {"FPIT1S", 0.0f, 15.0f, 24.0f, 14.0f}, + {"FIRE_1", 1.0f, 25.0f, 50.0f, 17.0f}, {"FIRE_4", 0.0f, 15.0f, 27.0f, 7.0f}, +}; +constexpr FlameGeometry kDefaultFlameGeometry{"", 0.0f, 4.0f, 34.0f, 8.0f}; + +const FlameGeometry& flame_geometry_for(std::string_view resref) noexcept { + for (const auto& entry : kFlameGeometry) { + if (entry.resref == resref) return entry; + } + return kDefaultFlameGeometry; +} +} // namespace + +bool should_replace_animation_draw(std::string_view resref, AreaAnimationKind kind) noexcept { + // Only standalone flame/smoke BAM families are replaced; per-area overlay + // art (AM*/AR* hearth and plume images, name-classified unknowns) keeps + // its engine draw. + const auto upper = upper_copy(resref); + if (kind == AreaAnimationKind::Fire) { + return starts_with_any(upper, {"FLAM", "FIRE", "FPIT", "FLM", "FLSM", "YSFL", "FIM", "TORCH", + "BRAZ"}); + } + if (kind == AreaAnimationKind::Smoke) { + return starts_with_any(upper, {"SMOK", "CHIM"}); + } + return false; +} + +namespace { +// Moves the point to the bottom-center of the authored draw box and sizes it +// to that box, mirroring the X range the way RenderBam does. False when the +// envelope is unknown or degenerate. +bool apply_envelope(const AreaAnimationInfo& animation, AreaEffectPoint& point) noexcept { + const auto& envelope = animation.envelope; + if (!envelope.valid || envelope.right <= envelope.left || envelope.bottom <= envelope.top) { + return false; + } + const float left = animation.isMirrored() ? -static_cast(envelope.right) + : static_cast(envelope.left); + const float right = animation.isMirrored() ? -static_cast(envelope.left) + : static_cast(envelope.right); + point.x += (left + right) / 2.0f; + point.y += static_cast(envelope.bottom); + point.height = static_cast(envelope.bottom - envelope.top); + point.halfWidth = (std::max)((right - left) / 2.0f, 1.5f); + return true; +} +} // namespace + +void extend_bam_envelope(BamEnvelope& envelope, const frameTableEntry_st& frame) noexcept { + if (frame.nWidth == 0 || frame.nHeight == 0) return; + const auto clamp16 = [](int value) { + return static_cast((std::clamp)(value, -32768, 32767)); + }; + const auto left = clamp16(-frame.nCenterX); + const auto top = clamp16(-frame.nCenterY); + const auto right = clamp16(static_cast(frame.nWidth) - frame.nCenterX); + const auto bottom = clamp16(static_cast(frame.nHeight) - frame.nCenterY); + if (!envelope.valid) { + envelope = {true, left, top, right, bottom}; + return; + } + envelope.left = (std::min)(envelope.left, left); + envelope.top = (std::min)(envelope.top, top); + envelope.right = (std::max)(envelope.right, right); + envelope.bottom = (std::max)(envelope.bottom, bottom); +} + +std::vector build_area_effect_points(const AreaAnimationsInfo& info) { + std::vector points; + points.reserve((std::min)(info.animations.size(), kMaxAreaEffectPoints)); + + const auto makePoint = [](const AreaAnimationInfo& animation) + -> std::optional { + AreaEffectPoint point{}; + // The engine renders from the live object position, not the header + // coordinates; both are logged so a divergence stays visible. RenderBam's + // screen Y is m_pos.y - m_posZ (elevation): a wall-mounted sconce with + // authored Z draws that many pixels above its map position, so the same + // subtraction anchors the replacement flame on the art. + point.x = static_cast(animation.objX); + point.y = static_cast(animation.objY - animation.objZ); + const float base = static_cast(static_cast(animation.kind)); + const auto resref = animation.resrefView(); + switch (animation.kind) { + case AreaAnimationKind::Fire: { + point.kind = base; + if (!should_replace_animation_draw(resref, animation.kind)) { + // Overlay art keeps rendering; contribute warm cast light only. + point.reserved1 = 2.0f; // palette id: glow only + point.height = 40.0f; + point.halfWidth = 10.0f; + return point; + } + const auto upper = upper_copy(resref); + point.reserved1 = upper.find("BLU") != std::string::npos ? 1.0f : 0.0f; // palette id + if (!apply_envelope(animation, point)) { + // BAM not loaded yet: reviewed per-resref table until the engine + // has drawn the object once. + const auto& geometry = flame_geometry_for(upper); + point.height = geometry.height; + point.halfWidth = geometry.halfWidth; + point.x += animation.isMirrored() ? -geometry.dx : geometry.dx; + point.y += geometry.dy; + } + return point; + } + case AreaAnimationKind::Smoke: { + if (!should_replace_animation_draw(resref, animation.kind)) { + return std::nullopt; // authored plume art stays; nothing to add + } + point.kind = base; + if (!apply_envelope(animation, point)) { + point.height = 170.0f; + point.halfWidth = 11.0f; + } + return point; + } + case AreaAnimationKind::Light: { + point.kind = base; + point.height = 54.0f; // glow radius + point.halfWidth = 0.0f; + return point; + } + default: + return std::nullopt; + } + }; + + // Fire carries the effect's visual identity; when an area exceeds the + // uniform capacity, drop smoke before light before fire. + const AreaAnimationKind passes[] = {AreaAnimationKind::Fire, AreaAnimationKind::Light, + AreaAnimationKind::Smoke}; + for (const auto pass : passes) { + for (const auto& animation : info.animations) { + if (animation.kind != pass || !animation.isShown()) continue; + if (points.size() >= kMaxAreaEffectPoints) return points; + if (const auto point = makePoint(animation)) points.push_back(*point); + } + } + return points; +} + +AreaAnimationInfo make_area_animation_info(const ARE_Animation_st& record) noexcept { + AreaAnimationInfo animation{}; + animation.x = record.nX; + animation.y = record.nY; + animation.objX = record.nX; + animation.objY = record.nY; + animation.objZ = record.nHeight; + animation.height = record.nHeight; + animation.schedule = record.nSchedule; + animation.flags = record.nFlags; + animation.translucency = record.nTranslucency; + animation.resref = copy_resref(record.rrAnimation); + animation.name.fill('\0'); + for (std::size_t c = 0; c < record.szName.size(); ++c) { + const auto character = static_cast(record.szName[c]); + if (character == '\0') break; + animation.name[c] = character; + } + animation.kind = classify_area_animation(animation.resrefView(), animation.nameView()); + return animation; +} +} // namespace iee::game diff --git a/src/iee/game/are_animations.h b/src/iee/game/are_animations.h new file mode 100644 index 0000000..696a2aa --- /dev/null +++ b/src/iee/game/are_animations.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "resref_runtime.h" + +namespace iee::game { +// Classification of an authored ARE ambient animation for point-effect +// placement (graphics roadmap §10.3: the authored classification channel for +// emissive candidates). Water bodies stay on the WED overlay path. +enum class AreaAnimationKind : int { + None = 0, + Fire = 1, + Smoke = 2, // includes authored fog/mist + Fountain = 3, + Light = 4, // candles, glows, lit night windows + Water = 5, // splashes, ripples, waterfalls, scrolling water + Lava = 6, + Wildlife = 7, // fish, flies, butterflies — explicitly no effect +}; + +[[nodiscard]] std::string_view area_animation_kind_name(AreaAnimationKind kind) noexcept; + +// Conservative resref/name prefix classification. Unknown entries return +// None; runtime logging of unclassified resrefs is the mechanism that grows +// the table from real game data. +[[nodiscard]] AreaAnimationKind classify_area_animation(std::string_view resref, + std::string_view name) noexcept; + +struct frameTableEntry_st; + +// Union of a BAM cycle's frame draw boxes, relative to the object anchor and +// unmirrored: RenderBam draws each frame's top-left at (anchor - center), so +// a frame spans [-cx, w - cx] x [-cy, h - cy]. Stable across the animation, +// unlike any single frame. +struct BamEnvelope { + bool valid{}; + std::int16_t left{}; + std::int16_t top{}; + std::int16_t right{}; + std::int16_t bottom{}; +}; + +// Grows `envelope` by one frame's draw box. Empty frames are ignored. +void extend_bam_envelope(BamEnvelope& envelope, const frameTableEntry_st& frame) noexcept; + +struct AreaAnimationInfo { + std::uint16_t x{}; + std::uint16_t y{}; + // Live CGameObject::m_pos — the position RenderBam actually draws from + // (can differ from the header x/y). Filled by the memory walk; the disk + // parser mirrors x/y. + std::int32_t objX{}; + std::int32_t objY{}; + // Live CGameObject::m_posZ. RenderBam's screen Y is m_pos.y - m_posZ (the + // elevation subtraction), so a mounted flame (wall sconce) draws above its + // map position. Loaded from the ARE record's height field; the memory walk + // overrides it with the live value. + std::int32_t objZ{}; + // Authored draw box from the BAM frame table. Valid only once the engine + // has loaded the BAM (the object has rendered at least once); until then + // the per-resref table is the fallback. + BamEnvelope envelope{}; + // The live CGameStatic this record came from (memory walk only). Identity + // key for the render-thread envelope cache; never dereferenced host-side. + const void* object{}; + std::int16_t height{}; + std::uint32_t schedule{}; + std::uint32_t flags{}; + std::uint16_t translucency{}; + ResrefBuffer resref{}; + // Authored editor label, NUL-terminated (source field is 32 bytes). + std::array name{}; + AreaAnimationKind kind{AreaAnimationKind::None}; + + [[nodiscard]] std::string_view resrefView() const noexcept { return resref_view(resref); } + [[nodiscard]] std::string_view nameView() const noexcept; + [[nodiscard]] bool isShown() const noexcept; + [[nodiscard]] bool isLightSource() const noexcept; + [[nodiscard]] bool isMirrored() const noexcept; +}; + +struct AreaAnimationsInfo { + ResrefBuffer areaResref{}; + std::vector animations{}; + + [[nodiscard]] std::string_view areaResrefView() const noexcept { + return resref_view(areaResref); + } + [[nodiscard]] std::size_t count_of(AreaAnimationKind kind) const noexcept; +}; + +// Shared ceiling for authored animation records, whichever channel supplies +// them (disk parse or the in-memory CGameStatic walk). +inline constexpr std::size_t kMaxAreaAnimationRecords = 4096; + +struct ARE_Animation_st; + +// Converts one raw authored record (identical layout on disk and inside a +// live CGameStatic) into a classified AreaAnimationInfo. +[[nodiscard]] AreaAnimationInfo make_area_animation_info(const ARE_Animation_st& record) noexcept; + +// Parses the animation section out of a complete ARE V1.0 file image. +// Bounded and fail-closed: malformed counts/offsets return false and leave +// `out` empty. Other ARE versions (e.g. IWD2 V9.1) are rejected. +[[nodiscard]] bool parse_are_animations(const std::byte* data, std::size_t size, + AreaAnimationsInfo& out) noexcept; + +// One shader point effect, two vec4 uniform slots per point. +// Slot A: base-center world position (anchor corrected by the authored BAM +// draw box), the integer kind, and effect height in world px. +// Slot B: half-width in world px, then the palette id (fire only: 0 warm +// body, 1 blue body, 2 glow-only — the engine keeps drawing the authored +// art); the rest is reserved. +struct AreaEffectPoint { + float x{}; + float y{}; + float kind{}; + float height{}; + float halfWidth{}; + float reserved1{}; // palette id for fire points + float reserved2{}; + float reserved3{}; +}; +static_assert(sizeof(AreaEffectPoint) == 8 * sizeof(float)); + +// The fpSEAM override's fixed capacity (points; the uniform array holds two +// vec4 slots per point). +inline constexpr std::size_t kMaxAreaEffectPoints = 32; + +// True when the animation's authored draw is a standalone flame/smoke BAM +// that the shader replaces outright (the CGameStatic::Render hook suppresses +// the engine draw). False for per-area overlay art (hearths, plume images) +// that must keep rendering; fire overlays then contribute glow only. +[[nodiscard]] bool should_replace_animation_draw(std::string_view resref, + AreaAnimationKind kind) noexcept; + +// Selects the shown fire/smoke/light animations as shader points, fire first +// so the capacity cap drops the least impactful kinds. Returns at most +// kMaxAreaEffectPoints entries. +[[nodiscard]] std::vector build_area_effect_points( + const AreaAnimationsInfo& info); +} // namespace iee::game diff --git a/src/iee/game/build_manifest.cpp b/src/iee/game/build_manifest.cpp index fc3002a..a6dc695 100644 --- a/src/iee/game/build_manifest.cpp +++ b/src/iee/game/build_manifest.cpp @@ -143,8 +143,15 @@ constexpr BuildManifest kKnownBuilds[] = { { "40 55 53 56 57 41 54 41 55 41 56 41 57 48 8D AC 24 48 FD FF FF", "48 8B C4 44 89 48 20 48 83 EC 48 48 89 58 08 8B DA 48 89 68 10", + // CGameObjectArray::GetShare (EEex InfinityLoader.db, version- + // independent section). Verified unique on 2.6.6.0 at 0x276490. + "48 C7 02 00 00 00 00 83 F9 FF", + // CGameStatic::Render prologue (offline-verified unique on both + // supported builds; PDB-named decompilation evidence). + "40 55 56 57 48 83 EC 50 48 8B 05 ? ? ? ? 48 33 C4 48 89 44 24 30 " + "48 8B 05 ? ? ? ? 49 8B F0 48 8B EA 48 8B F9", }, - {0x27E710, 0x4247E0}, + {0x27E710, 0x4247E0, 0x276490, 0x1F2570}, {0x100, 0x1DC, 0x14, 0x6590, 0x6598, 0x65F8}, {{ {"CRes_Demand", 0x36, BranchInstructionKind::CallRel32, 0xE8, 1, 5, true}, @@ -171,8 +178,42 @@ constexpr BuildManifest kKnownBuilds[] = { { "40 55 53 56 57 41 54 41 55 41 56 41 57 48 8D AC 24 48 FD FF FF", "48 8B C4 44 89 48 20 48 83 EC 48 48 89 58 08 8B DA 48 89 68 10", + // Same EEex version-independent pattern. Offline-verified on the + // 2.7.3.0 binary: unique match, identical body shape, globals at + // 0x68F8F4 (max index) / 0x68F910 (entry table). + "48 C7 02 00 00 00 00 83 F9 FF", + // CGameStatic::Render (offline-verified unique at 0x1F27D0). + "40 55 56 57 48 83 EC 50 48 8B 05 ? ? ? ? 48 33 C4 48 89 44 24 30 " + "48 8B 05 ? ? ? ? 49 8B F0 48 8B EA 48 8B F9", + // CGameSprite::Render / RenderMarkers / RenderHealthBar. PDB-named + // on the Steam 2.7.3.0 binary; each offline-verified unique. + "48 89 5C 24 10 55 56 57 41 54 41 55 41 56 41 57 48 8D 6C 24 A0 48 81 EC 60 01 00 00 " + "48 8B 05 ? ? ? ? 48 33 C4 48 89 45 20 45 33 E4", + "40 55 53 56 57 41 56 48 8B EC 48 83 EC 60 48 8B 41 18 4C 8B F2 48 8B F1", + "4C 8B DC 55 57 49 8D AB 48 FF FF FF 48 81 EC A8 01 00 00", + // CVidCell::GetFrame, then the shared GetCurrentCenterPoint / + // GetCurrentFrameSize body (PDB-named, offline-verified). + "40 53 48 83 EC 20 48 8B D9 48 8B 89 08 01 00 00 48 85 C9 0F", + "48 89 5C 24 10 48 89 74 24 18 57 48 83 EC 20 48 8B F9 33 DB 48 8B 89 08 01 00 00 " + "48 8B F2 48 85 C9 75 15 48 89 1A 33 C0", + // CInfinity::RenderFog and DrawFlush_GL (PDB-named, each + // offline-verified unique on the Steam 2.7.3.0 binary). + "48 89 5C 24 10 4C 89 44 24 18 55 56 57 41 54 41 55 41 56 41 57 48 83 EC 50 " + "48 8B D9 E8 ? ? ? ? B9 FF FF FF FF E8 ? ? ? ? B9 E1 0D 00 00", + "4C 8B DC 55 41 56 41 57 49 8D 6B D8 48 81 EC 10 01 00 00 48 8B 05 ? ? ? ? " + "48 33 C4 48 89 45 90 45 33 F6 44 39 35 ? ? ? ? 45 8B FE 0F 84", + // CParticle::AsynchronousUpdate and CParticle::Render (PDB-named, + // each offline-verified unique). + "48 89 5C 24 18 57 48 83 EC 20 8B 51 10 45 33 C0 48 8B D9 41 8B F8 8D 42 FF 89 41 10 " + "85 D2", + "48 89 5C 24 18 48 89 54 24 10 55 56 57 41 54 41 55 41 56 41 57 48 83 EC 40 " + "44 0F B7 29 4C 8B F9 66 44 3B 69 02", + // TexSubImage_GL (PDB-named, offline-verified unique). + "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 48 89 7C 24 20 41 56 48 83 EC 50 " + "44 8B F1 41 8B F0 44 8B 05 ? ? ? ? 48 8D 0D ? ? ? ? 41 8B C0 8B EA", }, - {0x27EBD0, 0x4257C0}, + {0x27EBD0, 0x4257C0, 0x276700, 0x1F27D0, 0x36BA50, 0x36F170, 0x36E820, 0x4118A0, 0x411660, + 0x411780, 0x2A1B60, 0x42B350, 0x423C30, 0x425BE0, 0x42D190}, {0x100, 0x1DC, 0x14, 0x6590, 0x6598, 0x65F8}, {{ {"CRes_Demand", 0x36, BranchInstructionKind::CallRel32, 0xE8, 1, 5, true}, @@ -187,6 +228,40 @@ constexpr BuildManifest kKnownBuilds[] = { {"DrawEnd", 0x17A, BranchInstructionKind::CallRel32, 0xE8, 1, 5, true}, {"DrawPopState", 0x1AD, BranchInstructionKind::JmpRel32, 0xE9, 1, 5, true}, }}, + // PDB-named on the Steam 2.7.3.0 binary; each pattern offline-verified + // unique, each function confirmed to draw from CGameObject::m_pos. + {{ + {"CProjectileBAM::Render", "4C 8B DC 55 41 54 41 56 49 8D 6B A1 48 81 EC C0 00 00 00", + 0x233F20}, + {"CProjectileScorcher::Render", + "4C 8B DC 55 57 49 8D 6B A1 48 81 EC C8 00 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 45 27", + 0x234A20}, + {"CProjectileNewScorcher::Render", "4C 8B DC 55 53 49 8D 6B A1 48 81 EC C8 00 00 00", + 0x234520}, + {"CProjectileSkyStrike::Render", "4C 8B DC 55 41 56 49 8D 6B A1 48 81 EC C8 00 00 00", + 0x234F50}, + {"CGameFireball3d::Render", + "48 89 5C 24 18 48 89 74 24 20 55 57 41 54 41 55 41 56 48 8D 6C 24 C9", 0x1EAF90}, + {"CGameTemporal::Render", "4C 8B DC 55 56 57 41 57 49 8D 6B A1 48 81 EC", 0x36CD80}, + {"CGameChunk::Render", "4C 8B DC 55 56 57 41 55 41 57 49 8D 6B A1", 0x36B7A0}, + {"CVEFVidCell::Render", "4C 8B DC 55 56 41 57 49 8D 6B A1 48 81 EC D0", 0x254B00}, + // Floating text over a creature: each tick it copies its target's + // m_pos into its own and draws from there. + {"CGameText::Render", + "40 57 48 81 EC B0 00 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 44 24 78 48 8B C2 " + "48 8B F9 4D 85 C0 74 15 48 63 51 48", + 0x1F39A0}, + }}, + // Disassembly-verified on the Steam 2.7.3.0 binary: DrawFlush_GL+0x24 + // compares gl.n (0x2F73FD0); DrawEnd_GL writes gl.cmds at 0x2F5BF28 + // (12 bytes each); DrawBlendFunc_GL packs src<<9 | dst<<13; the + // factor table at 0x5C12B0 has GL_ONE at index 1. + {0x24, 0x180A8, 8192, 8, 13, 1}, + // Disassembly-verified: gl.user.state at 0x2F73F6C, gl.textures at + // 0x757040 (0x28-byte entries, GL name first), fx[] at 0x2F74050 + // (0x30 apart: width +0, height +4, CPU buffer +0x20, texture index + // +0x28); the selected texture is bits 21-29 of the state word. + {0x64, 0x281CF90, 0x28, 0x80, 0x30, 2, 0x00, 0x04, 0x20, 0x28, 21, 0x1FF}, }, }; @@ -194,11 +269,49 @@ static_assert(validate_pattern_format(kKnownBuilds[0].patterns.loadArea), "LoadArea pattern format is invalid"); static_assert(validate_pattern_format(kKnownBuilds[0].patterns.renderTexture), "RenderTexture pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[0].patterns.objectArrayGetShare), + "GetShare pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[0].patterns.staticRender), + "StaticRender pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.staticRender), + "2.7.3 StaticRender pattern format is invalid"); static_assert(kKnownBuilds[0].validate(), "Known build manifest is invalid"); static_assert(validate_pattern_format(kKnownBuilds[1].patterns.loadArea), "2.7.3 LoadArea pattern format is invalid"); static_assert(validate_pattern_format(kKnownBuilds[1].patterns.renderTexture), "2.7.3 RenderTexture pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.objectArrayGetShare), + "2.7.3 GetShare pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.spriteRender) && + validate_pattern_format(kKnownBuilds[1].patterns.spriteRenderMarkers) && + validate_pattern_format(kKnownBuilds[1].patterns.spriteRenderHealthBar) && + validate_pattern_format(kKnownBuilds[1].patterns.vidCellGetFrame) && + validate_pattern_format(kKnownBuilds[1].patterns.vidCellFrameAccessor), + "2.7.3 sprite render pattern format is invalid"); +static_assert([] { + for (const auto& target : kKnownBuilds[1].smoothedObjectRenders) { + if (target.name && !validate_pattern_format(target.pattern)) return false; + } + return true; +}(), "2.7.3 smoothed object render pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.textureUpload), + "2.7.3 texture upload pattern format is invalid"); +// Positional initialisation guard: each slot must hold its own function's bytes. +static_assert(kKnownBuilds[1].patterns.renderFog.starts_with("48 89 5C 24 10 4C 89 44 24 18") && + kKnownBuilds[1].patterns.drawFlush.starts_with("4C 8B DC 55 41 56 41 57") && + kKnownBuilds[1].patterns.particleUpdate.starts_with( + "48 89 5C 24 18 57 48 83 EC 20 8B 51 10") && + kKnownBuilds[1].patterns.particleRender.starts_with( + "48 89 5C 24 18 48 89 54 24 10 55 56 57") && + kKnownBuilds[1].patterns.textureUpload.starts_with( + "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18"), + "a 2.7.3 hook pattern is in the wrong PatternSet slot"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.particleUpdate) && + validate_pattern_format(kKnownBuilds[1].patterns.particleRender), + "2.7.3 particle pattern format is invalid"); +static_assert(validate_pattern_format(kKnownBuilds[1].patterns.renderFog) && + validate_pattern_format(kKnownBuilds[1].patterns.drawFlush), + "2.7.3 world post pattern format is invalid"); static_assert(kKnownBuilds[1].validate(), "2.7.3 build manifest is invalid"); } // namespace diff --git a/src/iee/game/build_manifest.h b/src/iee/game/build_manifest.h index ee5eeee..9eae67e 100644 --- a/src/iee/game/build_manifest.h +++ b/src/iee/game/build_manifest.h @@ -28,14 +28,66 @@ struct BranchInstructionDesc { } }; +// Initialised positionally in build_manifest.cpp, in this order: add new +// members at the end, and pin each pattern's first bytes there with a +// static_assert so a value in the wrong slot cannot compile. struct PatternSet { std::string_view loadArea{}; std::string_view renderTexture{}; + // Optional: CGameObjectArray::GetShare. Sourced from EEex's + // version-independent binding pattern; resolves the static object-array + // globals for the ARE-animation memory path. Empty (or a non-unique match + // at runtime) disables that path — the disk reader remains the fallback. + std::string_view objectArrayGetShare{}; + // Optional: CGameStatic::Render. Hooked to suppress the engine's authored + // fire/smoke BAM draws while the fpSEAM point effects replace them. Empty + // or non-unique keeps the engine draws (effects stay additive). + std::string_view staticRender{}; + // Optional: CGameSprite::Render and the two per-sprite overlay renders that + // read the same position. Hooked together for sprite movement smoothing; + // any empty or non-unique entry leaves the feature off. + std::string_view spriteRender{}; + std::string_view spriteRenderMarkers{}; + std::string_view spriteRenderHealthBar{}; + // Optional: CVidCell frame resolution, hooked for draw-time animation + // interpolation. GetFrame has its own signature. GetCurrentCenterPoint and + // GetCurrentFrameSize are byte-identical for their first 150+ bytes, so + // they share one pattern that is confirmed at each reference RVA instead of + // being searched for. + std::string_view vidCellGetFrame{}; + std::string_view vidCellFrameAccessor{}; + // Optional: CInfinity::RenderFog and the GL backend's queue flush + // (DrawFlush_GL). Hooked/called together for the world post passes (soft + // fog of war, bloom); either empty or non-unique leaves both off. + std::string_view renderFog{}; + std::string_view drawFlush{}; + // Optional pair: CParticle::AsynchronousUpdate and CParticle::Render (rain, + // snow, sparkles). The update marks the logic tick; the render is wrapped + // to draw each particle part of the way through its step. + std::string_view particleUpdate{}; + std::string_view particleRender{}; + // Optional: TexSubImage_GL, the upload of the streaming sprite atlas at the + // start of a flush. Hooked to upscale the atlas before it is drawn from. + std::string_view textureUpload{}; }; struct ReferenceRvas { std::uintptr_t loadArea{}; std::uintptr_t renderTexture{}; + // Diagnostic only; 0 means "not yet observed on this build". + std::uintptr_t objectArrayGetShare{}; + std::uintptr_t staticRender{}; + std::uintptr_t spriteRender{}; + std::uintptr_t spriteRenderMarkers{}; + std::uintptr_t spriteRenderHealthBar{}; + std::uintptr_t vidCellGetFrame{}; + std::uintptr_t vidCellGetCurrentCenterPoint{}; + std::uintptr_t vidCellGetCurrentFrameSize{}; + std::uintptr_t renderFog{}; + std::uintptr_t drawFlush{}; + std::uintptr_t particleUpdate{}; + std::uintptr_t particleRender{}; + std::uintptr_t textureUpload{}; }; struct RuntimeOffsets { @@ -63,6 +115,59 @@ struct ExecutableVersion { } }; +// One per-object render (virtual Render(CGameArea*, CVidMode*) of a +// CGameObject subclass) that draws from CGameObject::m_pos and can therefore +// be movement-smoothed with the position swap. Each resolves independently. +// The streaming sprite atlases (fx[0], fx[1]) and the texture table, all in +// the same GL state block as the draw queue and addressed relative to gl.n. +// Sprites are composited on the CPU into an atlas's buffer, uploaded at the +// start of each flush, and drawn from the atlas's texture. +struct SpriteAtlasLayout { + std::uintptr_t userStateBeforeCount{}; // gl.user.state: selected texture in its bits + std::uintptr_t texturesBeforeCount{}; // gl.textures[0] + std::size_t textureEntrySize{}; // GL name is the first field of an entry + std::uintptr_t atlasAfterCount{}; // fx[0] + std::size_t atlasStride{}; + std::size_t atlasCount{}; + std::size_t widthOffset{}; + std::size_t heightOffset{}; + std::size_t texelsOffset{}; + std::size_t textureIndexOffset{}; + std::uint32_t textureShift{}; + std::uint32_t textureMask{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return texturesBeforeCount != 0 && textureEntrySize != 0 && atlasStride != 0 && + atlasCount != 0 && textureMask != 0; + } +}; + +struct ObjectRenderTarget { + const char* name{}; + std::string_view pattern{}; + std::uintptr_t referenceRva{}; +}; +inline constexpr std::size_t kMaxSmoothedObjectRenders = 12; + +// The GL backend's draw queue (gl.cmds / gl.n), consumed by DrawFlush_GL. +// Used to pick out the engine's additive (light-emitting) draws. +struct DrawQueueLayout { + // Offset inside DrawFlush_GL of `cmp [rip + gl.n], r14d`. + std::size_t countCompareOffset{}; + // gl.cmds starts this many bytes before gl.n. + std::uintptr_t commandsBeforeCount{}; + std::uint32_t maxCommands{}; + // Command state word: blend enable bit, destination factor field (4 bits), + // and the factor index that means GL_ONE. + std::uint32_t blendEnableBit{}; + std::uint32_t blendDstShift{}; + std::uint32_t blendFactorOne{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return countCompareOffset != 0 && commandsBeforeCount != 0 && maxCommands != 0; + } +}; + struct BuildManifest { std::string_view buildId{}; std::array supportedProductNames{}; @@ -71,6 +176,13 @@ struct BuildManifest { ReferenceRvas referenceRvas{}; RuntimeOffsets offsets{}; std::array renderTextureCallsites{}; + // Moving non-creature objects (projectiles, spell effect cells, debris). + // Unused slots stay empty. + std::array smoothedObjectRenders{}; + // Optional; an invalid (empty) layout leaves emissive bloom off. + DrawQueueLayout drawQueue{}; + // Optional; an invalid (empty) layout leaves sprite upscaling off. + SpriteAtlasLayout spriteAtlas{}; [[nodiscard]] constexpr bool validate() const noexcept { if (buildId.empty() || supportedProductNames[0].empty() || executableVersion.major == 0 || diff --git a/src/iee/game/draw_queue.cpp b/src/iee/game/draw_queue.cpp new file mode 100644 index 0000000..4937bc6 --- /dev/null +++ b/src/iee/game/draw_queue.cpp @@ -0,0 +1,82 @@ +#include "iee/game/draw_queue.h" + +#include + +namespace iee::game { +bool is_additive(const DrawQueueLayout& layout, std::uint32_t state) noexcept { + if (((state >> layout.blendEnableBit) & 1u) == 0) return false; + return ((state >> layout.blendDstShift) & 0xFu) == layout.blendFactorOne; +} + +std::size_t collect_additive(const DrawQueueLayout& layout, const DrawCommand* commands, + int count, std::span out) noexcept { + if (!commands || count <= 0 || static_cast(count) > layout.maxCommands) return 0; + std::size_t collected = 0; + for (int index = 0; index < count && collected < out.size(); ++index) { + const auto& command = commands[index]; + if (command.primCount <= 0 || !is_additive(layout, command.state)) continue; + out[collected++] = command; + } + return collected; +} + +std::uintptr_t draw_queue_count_address(const DrawQueueLayout& layout, + std::uintptr_t drawFlush) noexcept { + if (!drawFlush || !layout.valid()) return 0; + const auto* instruction = reinterpret_cast(drawFlush + layout.countCompareOffset); + // cmp dword ptr [rip + disp32], r14d + if (instruction[0] != 0x44 || instruction[1] != 0x39 || instruction[2] != 0x35) return 0; + std::int32_t displacement = 0; + std::memcpy(&displacement, instruction + 3, sizeof(displacement)); + const auto next = drawFlush + layout.countCompareOffset + 7; + return next + static_cast(static_cast(displacement)); +} + +std::uintptr_t draw_queue_commands_address(const DrawQueueLayout& layout, + std::uintptr_t countAddress) noexcept { + if (!countAddress || !layout.valid()) return 0; + return countAddress - layout.commandsBeforeCount; +} + +namespace { +template +T read_at(std::uintptr_t address) noexcept { + T value{}; + std::memcpy(&value, reinterpret_cast(address), sizeof(T)); + return value; +} +} // namespace + +int atlas_slot_for_upload(const SpriteAtlasLayout& layout, std::uintptr_t countAddress, + const void* pixels) noexcept { + if (!countAddress || !pixels || !layout.valid()) return -1; + const auto state = read_at(countAddress - layout.userStateBeforeCount); + const auto selected = (state >> layout.textureShift) & layout.textureMask; + for (std::size_t slot = 0; slot < layout.atlasCount; ++slot) { + const auto atlas = countAddress + layout.atlasAfterCount + slot * layout.atlasStride; + if (read_at(atlas + layout.texelsOffset) != + reinterpret_cast(pixels)) { + continue; + } + return read_at(atlas + layout.textureIndexOffset) == selected + ? static_cast(slot) + : -1; + } + return -1; +} + +SpriteAtlasInfo atlas_info(const SpriteAtlasLayout& layout, std::uintptr_t countAddress, + int slot) noexcept { + if (!countAddress || !layout.valid() || slot < 0 || + static_cast(slot) >= layout.atlasCount) { + return {}; + } + const auto atlas = + countAddress + layout.atlasAfterCount + static_cast(slot) * layout.atlasStride; + const auto index = read_at(atlas + layout.textureIndexOffset) & layout.textureMask; + return {read_at(atlas + layout.widthOffset), + read_at(atlas + layout.heightOffset), + reinterpret_cast(countAddress - layout.texturesBeforeCount + + index * layout.textureEntrySize)}; +} +} // namespace iee::game diff --git a/src/iee/game/draw_queue.h b/src/iee/game/draw_queue.h new file mode 100644 index 0000000..b410a77 --- /dev/null +++ b/src/iee/game/draw_queue.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +#include "iee/game/build_manifest.h" + +namespace iee::game { +// The GL backend queues every draw as one of these and submits the queue in +// DrawFlush_GL. Layout verified against DrawEnd_GL / DrawFlush_GL (2.7.3). +struct DrawCommand { + std::uint32_t state{}; + std::int32_t primStart{}; + // -1 marks a clear command; it carries no vertices. + std::int32_t primCount{}; +}; +static_assert(sizeof(DrawCommand) == 12, "DrawCommand must match the engine's 12-byte command"); + +// True when the command adds its colour to what is already drawn (destination +// factor GL_ONE). The engine draws its light-emitting art this way: fires, +// spell effects, glows. +bool is_additive(const DrawQueueLayout& layout, std::uint32_t state) noexcept; + +// Copies the additive, drawable commands of a queue into `out`, in order. +// Returns how many were copied; 0 for an implausible queue. +std::size_t collect_additive(const DrawQueueLayout& layout, const DrawCommand* commands, + int count, std::span out) noexcept; + +// Address of the queue's command count, read from the rip-relative compare at +// the start of DrawFlush_GL. 0 when the bytes there are not that instruction. +std::uintptr_t draw_queue_count_address(const DrawQueueLayout& layout, + std::uintptr_t drawFlush) noexcept; +std::uintptr_t draw_queue_commands_address(const DrawQueueLayout& layout, + std::uintptr_t countAddress) noexcept; + +// Which sprite atlas the engine is uploading, given the pixel buffer passed +// to its upload: the buffer must be that atlas's own, and the texture the +// engine has selected must be that atlas's texture. -1 for any other upload. +int atlas_slot_for_upload(const SpriteAtlasLayout& layout, std::uintptr_t countAddress, + const void* pixels) noexcept; + +struct SpriteAtlasInfo { + int width{}; + int height{}; + // The atlas's GL texture name inside the engine's texture table; the engine + // binds whatever is stored here. Null for an invalid slot. + std::uint32_t* textureName{}; +}; +SpriteAtlasInfo atlas_info(const SpriteAtlasLayout& layout, std::uintptr_t countAddress, + int slot) noexcept; +} // namespace iee::game diff --git a/src/iee/game/file_formats.h b/src/iee/game/file_formats.h index 3372885..a3729c2 100644 --- a/src/iee/game/file_formats.h +++ b/src/iee/game/file_formats.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace iee::game { @@ -115,6 +116,84 @@ namespace iee::game { std::uint32_t dwFlags{}; }; + // ARE V1.0 (BGEE). Only the fields this project consumes are modeled: + // the fixed header prefix that carries the animation section pointers and + // the 76-byte ambient-animation record (layout verified against + // NearInfinity's org.infinity.resource.are.{AreResource,Animation}). + struct ARE_Header_st { + std::uint32_t nFileType{}; // "AREA" + std::uint32_t nFileVersion{}; // "V1.0" + std::array rrWed{}; + std::uint32_t nLastSaved{}; + std::uint32_t nAreaFlags{}; + std::array edges{}; // 4 x (resref + edge flags) + std::uint16_t nLocationFlags{}; // +0x48 + std::uint16_t nRainProbability{}; + std::uint16_t nSnowProbability{}; + std::uint16_t nFogProbability{}; + std::uint16_t nLightningProbability{}; + std::uint8_t nOverlayTransparency{}; // EE; classic wind speed low byte + std::uint8_t ___u0{}; + std::uint32_t nActorsOffset{}; // +0x54 + std::uint16_t nActors{}; + std::uint16_t nTriggers{}; + std::uint32_t nTriggersOffset{}; + std::uint32_t nSpawnPointsOffset{}; + std::uint32_t nSpawnPoints{}; + std::uint32_t nEntrancesOffset{}; + std::uint32_t nEntrances{}; + std::uint32_t nContainersOffset{}; + std::uint16_t nContainers{}; + std::uint16_t nItems{}; + std::uint32_t nItemsOffset{}; + std::uint32_t nVerticesOffset{}; + std::uint16_t nVertices{}; + std::uint16_t nAmbients{}; + std::uint32_t nAmbientsOffset{}; + std::uint32_t nVariablesOffset{}; + std::uint16_t nVariables{}; + std::uint16_t nObjectFlags{}; + std::uint32_t nObjectFlagsOffset{}; + std::array rrAreaScript{}; + std::uint32_t nExploredBitmapSize{}; + std::uint32_t nExploredBitmapOffset{}; + std::uint32_t nDoors{}; + std::uint32_t nDoorsOffset{}; + std::uint32_t nAnimations{}; // +0xAC + std::uint32_t nAnimationsOffset{}; // +0xB0 + }; + + struct ARE_Animation_st { + std::array szName{}; + std::uint16_t nX{}; + std::uint16_t nY{}; + std::uint32_t nSchedule{}; // hour-of-day bitmask + std::array rrAnimation{}; // BAM; EE also WBM/PVRZ + std::uint16_t nAnimationIndex{}; + std::uint16_t nFrameIndex{}; + std::uint32_t nFlags{}; + std::int16_t nHeight{}; + std::uint16_t nTranslucency{}; + std::uint16_t nStartRange{}; + std::uint8_t nLoopProbability{}; + std::uint8_t nStartDelay{}; + std::array rrPalette{}; + std::uint16_t nMovieWidth{}; // EE + std::uint16_t nMovieHeight{}; // EE + }; + + // ARE_Animation_st.nFlags bits (NearInfinity FLAGS_ARRAY, EE variant). + inline constexpr std::uint32_t kAreAnimationFlagIsShown = 1u << 0; + inline constexpr std::uint32_t kAreAnimationFlagNoShadow = 1u << 1; + inline constexpr std::uint32_t kAreAnimationFlagNotLightSource = 1u << 2; + inline constexpr std::uint32_t kAreAnimationFlagDrawAsBackground = 1u << 8; + // RenderBam also draws every secondary cell (one per BAM sequence). + inline constexpr std::uint32_t kAreAnimationFlagAllSequences = 1u << 9; + // RenderBam flips the frame box around the object X (CInfinity::MIRROR_FX). + inline constexpr std::uint32_t kAreAnimationFlagMirror = 1u << 11; + inline constexpr std::uint32_t kAreAnimationFlagUseWbm = 1u << 13; + inline constexpr std::uint32_t kAreAnimationFlagUsePvrz = 1u << 15; + struct bamHeader_st { std::uint32_t nFileType{}; std::uint32_t nFileVersion{}; @@ -151,6 +230,13 @@ namespace iee::game { std::uint32_t ___u4{}; }; + // BAM cycle entry; identical shape in V1 and V2. V1 indexes the frame + // lookup list from nStartingFrame, V2 indexes the frame table directly. + struct sequenceTableEntry_st { + std::int16_t nFrames{}; + std::uint16_t nStartingFrame{}; + }; + struct st_tiledef { std::int32_t nTile{}; std::int32_t nUsageCount{}; @@ -171,9 +257,16 @@ namespace iee::game { static_assert(sizeof(WED_TileData_st) == 0xA); static_assert(sizeof(WED_TiledObject_st) == 0x1A); static_assert(sizeof(WED_WedHeader_st) == 0x2C); + static_assert(sizeof(ARE_Header_st) == 0xB4); + static_assert(offsetof(ARE_Header_st, nAnimations) == 0xAC); + static_assert(offsetof(ARE_Header_st, nAnimationsOffset) == 0xB0); + static_assert(sizeof(ARE_Animation_st) == 0x4C); + static_assert(offsetof(ARE_Animation_st, rrAnimation) == 0x28); + static_assert(offsetof(ARE_Animation_st, nFlags) == 0x34); static_assert(sizeof(bamHeader_st) == 0x18); static_assert(sizeof(BAMHEADERV2) == 0x20); static_assert(sizeof(frame) == 0x18); static_assert(sizeof(frameTableEntry_st) == 0xC); + static_assert(sizeof(sequenceTableEntry_st) == 0x4); static_assert(sizeof(st_tiledef) == 0x18); } diff --git a/src/iee/game/game_addrs.cpp b/src/iee/game/game_addrs.cpp index cfeb8b2..9331476 100644 --- a/src/iee/game/game_addrs.cpp +++ b/src/iee/game/game_addrs.cpp @@ -1,3 +1,4 @@ +#include "iee/game/draw_queue.h" #include "game_addrs.h" #include @@ -13,7 +14,6 @@ namespace iee::game { bool resolve_addresses(GameAddresses &out, const core::EngineConfig &cfg, const BuildManifest &manifest) { - (void) cfg; out = {}; auto moduleInfo = core::get_module_span(nullptr); if (!moduleInfo || !moduleInfo->base || !moduleInfo->size) { @@ -62,6 +62,141 @@ namespace iee::game { recover("RenderTexture", out.RenderTexture, renderTextureMatches, manifest.referenceRvas.renderTexture, manifest.patterns.renderTexture); + // Optional target: point-effect BAM replacement. Failure only keeps + // the engine's authored draws; never blocks initialization. + if (!manifest.patterns.staticRender.empty()) { + std::size_t staticRenderMatches = 0; + out.StaticRender = reinterpret_cast(core::find_unique_in_module( + nullptr, manifest.patterns.staticRender, &staticRenderMatches)); + recover("StaticRender", out.StaticRender, staticRenderMatches, + manifest.referenceRvas.staticRender, manifest.patterns.staticRender); + if (out.StaticRender) { + LOG_INFO("CGameStatic::Render resolved at RVA 0x{:X} (reference 0x{:X})", + out.StaticRender - moduleBase, manifest.referenceRvas.staticRender); + } else { + LOG_WARN("CGameStatic::Render pattern matched {} times; authored fire/smoke " + "draws will not be replaced", + staticRenderMatches); + } + } + + const auto resolveOptional = [&](const char *name, std::string_view pattern, + std::uintptr_t referenceRva) -> std::uintptr_t { + if (pattern.empty()) return 0; + std::size_t matches = 0; + auto target = reinterpret_cast( + core::find_unique_in_module(nullptr, pattern, &matches)); + recover(name, target, matches, referenceRva, pattern); + if (!target) LOG_WARN("{} pattern matched {} times", name, matches); + return target; + }; + + // Optional targets: sprite movement smoothing. Resolved only when + // requested, and only as a complete set. + if (cfg.smoothSpriteMovement || cfg.interpolateAnimations) { + const auto &patterns = manifest.patterns; + const auto &rvas = manifest.referenceRvas; + out.SpriteRender = + resolveOptional("CGameSprite::Render", patterns.spriteRender, rvas.spriteRender); + out.SpriteRenderMarkers = resolveOptional( + "CGameSprite::RenderMarkers", patterns.spriteRenderMarkers, rvas.spriteRenderMarkers); + out.SpriteRenderHealthBar = + resolveOptional("CGameSprite::RenderHealthBar", patterns.spriteRenderHealthBar, + rvas.spriteRenderHealthBar); + if (cfg.interpolateAnimations) { + // The twin accessors cannot be told apart by signature, so each + // is confirmed in full at its reference RVA on this + // identity-checked build. + const auto confirmTwin = [&](std::uintptr_t rva) -> std::uintptr_t { + if (!rva || patterns.vidCellFrameAccessor.empty()) return 0; + return reinterpret_cast( + core::confirm_pattern_with_patched_prologue( + nullptr, rva, patterns.vidCellFrameAccessor, 0)); + }; + out.VidCellGetFrame = resolveOptional("CVidCell::GetFrame", + patterns.vidCellGetFrame, + rvas.vidCellGetFrame); + out.VidCellGetCurrentCenterPoint = confirmTwin(rvas.vidCellGetCurrentCenterPoint); + out.VidCellGetCurrentFrameSize = confirmTwin(rvas.vidCellGetCurrentFrameSize); + if (!(out.VidCellGetFrame && out.VidCellGetCurrentCenterPoint && + out.VidCellGetCurrentFrameSize)) { + out.VidCellGetFrame = out.VidCellGetCurrentCenterPoint = + out.VidCellGetCurrentFrameSize = 0; + LOG_WARN("Animation interpolation disabled: this build has no complete set " + "of CVidCell frame targets"); + } + } + for (std::size_t index = 0; + cfg.smoothSpriteMovement && index < manifest.smoothedObjectRenders.size(); + ++index) { + const auto &target = manifest.smoothedObjectRenders[index]; + if (!target.name) continue; + out.SmoothedObjectRenders[index] = + resolveOptional(target.name, target.pattern, target.referenceRva); + } + if (cfg.smoothSpriteMovement) { + out.ParticleUpdate = resolveOptional("CParticle::AsynchronousUpdate", + patterns.particleUpdate, rvas.particleUpdate); + out.ParticleRender = resolveOptional("CParticle::Render", patterns.particleRender, + rvas.particleRender); + if (!(out.ParticleUpdate && out.ParticleRender)) { + out.ParticleUpdate = out.ParticleRender = 0; + } + } + if (out.SpriteRender && out.SpriteRenderMarkers && out.SpriteRenderHealthBar) { + LOG_INFO("Sprite render targets resolved at RVA 0x{:X} / 0x{:X} / 0x{:X}", + out.SpriteRender - moduleBase, out.SpriteRenderMarkers - moduleBase, + out.SpriteRenderHealthBar - moduleBase); + } else { + out.SpriteRender = out.SpriteRenderMarkers = out.SpriteRenderHealthBar = 0; + LOG_WARN("Sprite movement smoothing disabled: this build has no complete set of " + "sprite render targets"); + } + } + + // Optional targets: world post passes. Resolved only when requested + // (or when debug hotkeys can switch them on), and only as a pair. + if (cfg.softFogOfWar || cfg.bloom || cfg.spriteUpscale || cfg.enableDebugHotkeys) { + out.RenderFog = resolveOptional("CInfinity::RenderFog", manifest.patterns.renderFog, + manifest.referenceRvas.renderFog); + out.DrawFlush = resolveOptional("DrawFlush_GL", manifest.patterns.drawFlush, + manifest.referenceRvas.drawFlush); + if (out.RenderFog && out.DrawFlush) { + LOG_INFO("World post targets resolved at RVA 0x{:X} / 0x{:X}", + out.RenderFog - moduleBase, out.DrawFlush - moduleBase); + out.DrawQueueCount = draw_queue_count_address(manifest.drawQueue, out.DrawFlush); + out.DrawQueueCommands = + draw_queue_commands_address(manifest.drawQueue, out.DrawQueueCount); + if (out.DrawQueueCount && out.DrawQueueCommands) { + LOG_INFO("Draw queue resolved: count at RVA 0x{:X}, commands at RVA 0x{:X}", + out.DrawQueueCount - moduleBase, out.DrawQueueCommands - moduleBase); + if (manifest.spriteAtlas.valid() && + (cfg.spriteUpscale || cfg.enableDebugHotkeys)) { + out.TextureUpload = + resolveOptional("TexSubImage_GL", manifest.patterns.textureUpload, + manifest.referenceRvas.textureUpload); + if (!out.TextureUpload && cfg.spriteUpscale) { + LOG_WARN("Sprite upscaling disabled: TexSubImage_GL did not resolve"); + } + } + } else { + out.DrawQueueCount = out.DrawQueueCommands = 0; + if (cfg.bloom) { + LOG_WARN("Bloom disabled: the draw queue was not found in DrawFlush_GL"); + } + } + } else { + const bool known = + !manifest.patterns.renderFog.empty() && !manifest.patterns.drawFlush.empty(); + out.RenderFog = out.DrawFlush = 0; + if (cfg.softFogOfWar || cfg.bloom || cfg.spriteUpscale) { + LOG_WARN("Soft fog of war, bloom and sprite upscaling disabled: {}", + known ? "RenderFog/DrawFlush did not resolve uniquely" + : "this build has no RenderFog/DrawFlush targets"); + } + } + } + const bool success = out.LoadArea && out.RenderTexture; if (success) { diff --git a/src/iee/game/game_addrs.h b/src/iee/game/game_addrs.h index d5a2c5e..22455cf 100644 --- a/src/iee/game/game_addrs.h +++ b/src/iee/game/game_addrs.h @@ -1,16 +1,47 @@ #pragma once +#include #include +#include "iee/game/build_manifest.h" + namespace iee { namespace core { struct EngineConfig; } } namespace iee::game { - struct BuildManifest; struct GameAddresses { std::uintptr_t LoadArea = 0; std::uintptr_t RenderTexture = 0; + // Optional (0 when the pattern did not resolve uniquely): the point + // effects then stay additive instead of replacing the engine draws. + std::uintptr_t StaticRender = 0; + // Optional, all-or-nothing: sprite movement smoothing needs every + // render that reads the sprite position. + std::uintptr_t SpriteRender = 0; + std::uintptr_t SpriteRenderMarkers = 0; + std::uintptr_t SpriteRenderHealthBar = 0; + // Optional, all-or-nothing: draw-time animation interpolation. + std::uintptr_t VidCellGetFrame = 0; + std::uintptr_t VidCellGetCurrentCenterPoint = 0; + std::uintptr_t VidCellGetCurrentFrameSize = 0; + // Optional, each independent; 0 where unresolved. Indexed like + // BuildManifest::smoothedObjectRenders. + std::array SmoothedObjectRenders{}; + // Optional pair: particle (rain, snow, sparkle) movement smoothing. + std::uintptr_t ParticleUpdate = 0; + std::uintptr_t ParticleRender = 0; + // Optional, all-or-nothing: the world post passes (soft fog of war, + // bloom) need the fog render to hook and the queue flush to call. + std::uintptr_t RenderFog = 0; + std::uintptr_t DrawFlush = 0; + // Optional: the draw queue DrawFlush_GL consumes (count and command + // array). 0 leaves the fog and flush hooks working without bloom. + std::uintptr_t DrawQueueCount = 0; + std::uintptr_t DrawQueueCommands = 0; + // Optional: the sprite atlas upload (TexSubImage_GL), for sprite + // upscaling. Needs the draw queue. + std::uintptr_t TextureUpload = 0; bool initialized = false; }; diff --git a/src/iee/game/object_statics.cpp b/src/iee/game/object_statics.cpp new file mode 100644 index 0000000..43b5e8b --- /dev/null +++ b/src/iee/game/object_statics.cpp @@ -0,0 +1,223 @@ +#include "object_statics.h" + +#include +#include +#include + +#include "iee/core/pattern_scanner.h" + +namespace iee::game { +namespace { +// GetShare instruction encodings holding the RIP-relative globals: +// 66 39 05 cmp WORD PTR [rip+disp], ax -> m_maxArrayIndex +// 4C 8D 05 lea r8, [rip+disp] -> entry table +constexpr std::size_t kRipInstructionSize = 7; + +const std::byte* decode_rip_operand(const std::byte* instruction) noexcept { + std::int32_t displacement = 0; + std::memcpy(&displacement, instruction + 3, sizeof(displacement)); + return instruction + kRipInstructionSize + displacement; +} + +bool matches(const std::byte* code, std::initializer_list bytes) noexcept { + std::size_t index = 0; + for (const auto expected : bytes) { + if (std::to_integer(code[index]) != expected) return false; + ++index; + } + return true; +} +} // namespace + +bool decode_object_array_globals(const std::byte* function, std::size_t windowSize, + ObjectArrayGlobals& out) noexcept { + out = {}; + if (!function || windowSize < kRipInstructionSize || + !core::is_readable(function, windowSize)) { + return false; + } + + const std::byte* maxIndexAddress = nullptr; + const std::byte* entriesAddress = nullptr; + bool ambiguous = false; + for (std::size_t offset = 0; offset + kRipInstructionSize <= windowSize; ++offset) { + const auto* code = function + offset; + if (matches(code, {0x66, 0x39, 0x05})) { + if (maxIndexAddress) ambiguous = true; + maxIndexAddress = decode_rip_operand(code); + } else if (matches(code, {0x4C, 0x8D, 0x05})) { + if (entriesAddress) ambiguous = true; + entriesAddress = decode_rip_operand(code); + } + } + + if (ambiguous || !maxIndexAddress || !entriesAddress) { + return false; + } + + out.maxArrayIndex = reinterpret_cast(maxIndexAddress); + out.entries = reinterpret_cast(entriesAddress); + return true; +} + +namespace { +// Authored flame/smoke cycles are short; anything beyond these bounds is +// treated as a stale or foreign pointer rather than walked. +constexpr std::size_t kMaxEnvelopeSequences = 64; +constexpr std::size_t kMaxEnvelopeFramesPerSequence = 256; +constexpr std::uint16_t kMaxEnvelopeFrameDimension = 1024; +constexpr std::uint16_t kBamNoFrame = 0xFFFF; + +bool read_envelope_frame(const CResCell& cell, std::size_t frameIndex, std::size_t frameCount, + BamEnvelope& envelope) noexcept { + if (frameIndex >= frameCount) return false; + frameTableEntry_st frame{}; + if (!core::safe_read(cell.m_pFrames + frameIndex, frame)) return false; + if (frame.nWidth > kMaxEnvelopeFrameDimension || frame.nHeight > kMaxEnvelopeFrameDimension) { + return false; + } + extend_bam_envelope(envelope, frame); + return true; +} +} // namespace + +bool read_static_bam_envelope(const void* staticObject, BamEnvelope& out) noexcept { + out = {}; + if (!staticObject) return false; + try { + const auto* objectBytes = static_cast(staticObject); + CVidCell vidCell{}; + ARE_Animation_st header{}; + if (!core::safe_read(objectBytes + offsetof(CGameStatic, m_vidCell), vidCell) || + !core::safe_read(objectBytes + offsetof(CGameStatic, m_header), header) || + !vidCell.pRes) { + return false; + } + CResCell cell{}; + if (!core::safe_read(vidCell.pRes, cell) || !cell.baseclass_0.bLoaded || + !cell.baseclass_0.pData || !cell.m_pFrames || !cell.m_pSequences) { + return false; + } + + // V2 (PVRZ-backed) cycles index the frame table directly; V1 goes + // through the frame lookup list. + std::size_t frameCount = 0; + std::size_t sequenceCount = 0; + bool direct = false; + if (cell.m_pBamHeaderV2) { + BAMHEADERV2 v2{}; + if (!core::safe_read(cell.m_pBamHeaderV2, v2)) return false; + frameCount = v2.nFrames; + sequenceCount = v2.nSequences; + direct = true; + } else if (cell.m_pBamHeader && cell.m_pFrameList) { + bamHeader_st v1{}; + if (!core::safe_read(cell.m_pBamHeader, v1)) return false; + frameCount = v1.nFrames; + sequenceCount = v1.nSequences; + } + if (frameCount == 0 || sequenceCount == 0) return false; + + std::size_t first = vidCell.m_nCurrentSequence < sequenceCount ? vidCell.m_nCurrentSequence : 0; + std::size_t last = first + 1; + if ((header.nFlags & kAreAnimationFlagAllSequences) != 0) { + first = 0; + last = (std::min)(sequenceCount, kMaxEnvelopeSequences); + } + + BamEnvelope envelope{}; + for (std::size_t sequence = first; sequence < last; ++sequence) { + sequenceTableEntry_st entry{}; + if (!core::safe_read(cell.m_pSequences + sequence, entry)) return false; + if (entry.nFrames <= 0 || entry.nStartingFrame == kBamNoFrame) continue; + const auto count = + (std::min)(static_cast(entry.nFrames), kMaxEnvelopeFramesPerSequence); + for (std::size_t offset = 0; offset < count; ++offset) { + std::size_t frameIndex = static_cast(entry.nStartingFrame) + offset; + if (!direct) { + // CResCell::Parse never fills m_nFrameList (it stays 0), so the + // lookup list has no usable length; the frame-count check on the + // listed index and the bounded reads are the guard. + std::uint16_t listed = kBamNoFrame; + if (!core::safe_read(cell.m_pFrameList + frameIndex, listed)) return false; + if (listed == kBamNoFrame) continue; + frameIndex = listed; + } + if (!read_envelope_frame(cell, frameIndex, frameCount, envelope)) return false; + } + } + if (!envelope.valid) return false; + out = envelope; + return true; + } catch (...) { + out = {}; + return false; + } +} + +bool collect_area_static_animations(const ObjectArrayGlobals& globals, const CGameArea* area, + AreaAnimationsInfo& out) noexcept { + out = {}; + if (!globals.valid() || !area) { + return false; + } + + std::int16_t maxIndex = 0; + if (!core::safe_read(globals.maxArrayIndex, maxIndex) || maxIndex < 0) { + return false; + } + const auto entryCount = + (std::min)(static_cast(maxIndex) + 1, kObjectArrayMaxEntries); + + try { + for (std::size_t index = 0; index < entryCount; ++index) { + CGameObjectArrayEntry entry{}; + if (!core::safe_read(globals.entries + index, entry) || !entry.m_objectPtr) { + continue; + } + + const auto* objectBytes = reinterpret_cast(entry.m_objectPtr); + std::uint8_t objectType = 0; + if (!core::safe_read(objectBytes + offsetof(CGameObject, m_objectType), objectType) || + objectType != kGameObjectTypeStatic) { + continue; + } + const CGameArea* owner = nullptr; + if (!core::safe_read(objectBytes + offsetof(CGameObject, m_pArea), owner) || + owner != area) { + continue; + } + + ARE_Animation_st record{}; + if (!core::safe_read(objectBytes + offsetof(CGameStatic, m_header), record)) { + continue; + } + auto info = make_area_animation_info(record); + // RenderBam draws from the live CGameObject position, not the header + // coordinates; prefer it when readable. + CPoint objectPos{}; + if (core::safe_read(objectBytes + offsetof(CGameObject, m_pos), objectPos)) { + info.objX = objectPos.x; + info.objY = objectPos.y; + } + std::int32_t objectPosZ = 0; + if (core::safe_read(objectBytes + offsetof(CGameObject, m_posZ), objectPosZ)) { + info.objZ = objectPosZ; + } + info.object = entry.m_objectPtr; + // Valid only once the engine has loaded the BAM; the render-thread + // envelope cache covers statics whose resource was released since. + (void)read_static_bam_envelope(entry.m_objectPtr, info.envelope); + out.animations.push_back(info); + if (out.animations.size() >= kMaxAreaAnimationRecords) { + break; + } + } + } catch (...) { + out = {}; + return false; + } + + return true; +} +} // namespace iee::game diff --git a/src/iee/game/object_statics.h b/src/iee/game/object_statics.h new file mode 100644 index 0000000..9acc278 --- /dev/null +++ b/src/iee/game/object_statics.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include "are_animations.h" +#include "runtime_types_x64.h" + +namespace iee::game { +// Resolved CGameObjectArray statics. On the validated builds the entry table +// is a fixed static array (not a heap pointer): CGameObjectArray::GetShare +// loads its address with a RIP-relative lea. See +// docs/are-animation-detection.md for the reverse-engineering evidence. +struct ObjectArrayGlobals { + const CGameObjectArrayEntry* entries{}; + const std::int16_t* maxArrayIndex{}; + + [[nodiscard]] bool valid() const noexcept { return entries != nullptr && maxArrayIndex != nullptr; } +}; + +// 15-bit index space per the GetShare locator encoding. +inline constexpr std::size_t kObjectArrayMaxEntries = 0x8000; + +// Decodes the object-array globals out of CGameObjectArray::GetShare's body: +// `cmp WORD PTR [rip+d], ax` (m_maxArrayIndex) and `lea r8, [rip+d]` (entry +// table). `function` points at the manifest pattern match; the scan is +// bounded to `windowSize` bytes and each instruction must occur exactly once +// or the decode fails closed. +[[nodiscard]] bool decode_object_array_globals(const std::byte* function, std::size_t windowSize, + ObjectArrayGlobals& out) noexcept; + +// Reads the authored draw box of a live CGameStatic's BAM straight from the +// engine's parsed frame table (CVidCell::pRes -> CResCell): the union over +// the current cycle, or over every cycle when the record draws all sequences. +// Bounded safe reads only. Returns false until the engine has loaded the BAM +// (first draw) and for anything that fails validation. +[[nodiscard]] bool read_static_bam_envelope(const void* staticObject, + BamEnvelope& out) noexcept; + +// Walks the engine object array and collects the authored static-animation +// records (CGameStatic::m_header) owned by `area` into classified entries. +// Returns false when the array is unresolved or unreadable; an area with no +// statics yields true with an empty list. +[[nodiscard]] bool collect_area_static_animations(const ObjectArrayGlobals& globals, + const CGameArea* area, + AreaAnimationsInfo& out) noexcept; +} // namespace iee::game diff --git a/src/iee/game/opengl_types.cpp b/src/iee/game/opengl_types.cpp index a3272ac..7842827 100644 --- a/src/iee/game/opengl_types.cpp +++ b/src/iee/game/opengl_types.cpp @@ -53,7 +53,7 @@ bool check_error(const char* operation) noexcept { // GetProcAddress on the opengl32.dll module handle instead. static void* get_gl1_proc_address(HMODULE opengl32, const char* name) noexcept { if (!opengl32) return nullptr; - return GetProcAddress(opengl32, name); + return reinterpret_cast(GetProcAddress(opengl32, name)); } // Load an extension entry point. Try wglGetProcAddress first (correct path @@ -75,7 +75,7 @@ static void* get_ext_proc_address(HMODULE opengl32, const char* name) noexcept { return proc; } } - return GetProcAddress(opengl32, name); + return reinterpret_cast(GetProcAddress(opengl32, name)); } HGLRC current_context() noexcept { @@ -111,6 +111,17 @@ bool OpenGLFunctions::initialize() noexcept { reinterpret_cast(get_gl1_proc_address(opengl32, "glDeleteTextures")); glPixelStorei = reinterpret_cast(get_gl1_proc_address(opengl32, "glPixelStorei")); + glViewport = reinterpret_cast(get_gl1_proc_address(opengl32, "glViewport")); + glEnable = reinterpret_cast(get_gl1_proc_address(opengl32, "glEnable")); + glDisable = reinterpret_cast(get_gl1_proc_address(opengl32, "glDisable")); + glIsEnabled = reinterpret_cast(get_gl1_proc_address(opengl32, "glIsEnabled")); + glBlendFunc = reinterpret_cast(get_gl1_proc_address(opengl32, "glBlendFunc")); + glClearColor = + reinterpret_cast(get_gl1_proc_address(opengl32, "glClearColor")); + glClear = reinterpret_cast(get_gl1_proc_address(opengl32, "glClear")); + glDrawArrays = + reinterpret_cast(get_gl1_proc_address(opengl32, "glDrawArrays")); + glGetFloatv = reinterpret_cast(get_gl1_proc_address(opengl32, "glGetFloatv")); glGetString = reinterpret_cast(get_gl1_proc_address(opengl32, "glGetString")); @@ -150,6 +161,8 @@ bool OpenGLFunctions::initialize() noexcept { glUniform1i = reinterpret_cast(get_ext_proc_address(opengl32, "glUniform1i")); glUniform2f = reinterpret_cast(get_ext_proc_address(opengl32, "glUniform2f")); glUniform3f = reinterpret_cast(get_ext_proc_address(opengl32, "glUniform3f")); + glUniform4fv = + reinterpret_cast(get_ext_proc_address(opengl32, "glUniform4fv")); glActiveTexture = reinterpret_cast(get_ext_proc_address(opengl32, "glActiveTexture")); glCompressedTexImage2D = reinterpret_cast( @@ -158,6 +171,33 @@ bool OpenGLFunctions::initialize() noexcept { reinterpret_cast(get_ext_proc_address(opengl32, "glGenerateMipmap")); glBindFramebuffer = reinterpret_cast(get_ext_proc_address(opengl32, "glBindFramebuffer")); + glGenFramebuffers = reinterpret_cast( + get_ext_proc_address(opengl32, "glGenFramebuffers")); + glDeleteFramebuffers = reinterpret_cast( + get_ext_proc_address(opengl32, "glDeleteFramebuffers")); + glFramebufferTexture2D = reinterpret_cast( + get_ext_proc_address(opengl32, "glFramebufferTexture2D")); + glCheckFramebufferStatus = reinterpret_cast( + get_ext_proc_address(opengl32, "glCheckFramebufferStatus")); + glBlitFramebuffer = reinterpret_cast( + get_ext_proc_address(opengl32, "glBlitFramebuffer")); + glGenVertexArrays = reinterpret_cast( + get_ext_proc_address(opengl32, "glGenVertexArrays")); + glDeleteVertexArrays = reinterpret_cast( + get_ext_proc_address(opengl32, "glDeleteVertexArrays")); + glBindVertexArray = reinterpret_cast( + get_ext_proc_address(opengl32, "glBindVertexArray")); + glGenBuffers = reinterpret_cast(get_ext_proc_address(opengl32, "glGenBuffers")); + glDeleteBuffers = + reinterpret_cast(get_ext_proc_address(opengl32, "glDeleteBuffers")); + glBindBuffer = reinterpret_cast(get_ext_proc_address(opengl32, "glBindBuffer")); + glBufferData = reinterpret_cast(get_ext_proc_address(opengl32, "glBufferData")); + glEnableVertexAttribArray = reinterpret_cast( + get_ext_proc_address(opengl32, "glEnableVertexAttribArray")); + glVertexAttribPointer = reinterpret_cast( + get_ext_proc_address(opengl32, "glVertexAttribPointer")); + glBlendFuncSeparate = reinterpret_cast( + get_ext_proc_address(opengl32, "glBlendFuncSeparate")); glIsProgram = reinterpret_cast(get_ext_proc_address(opengl32, "glIsProgram")); glShaderSourceARB = reinterpret_cast(get_ext_proc_address(opengl32, "glShaderSourceARB")); @@ -196,15 +236,24 @@ bool OpenGLFunctions::initialize() noexcept { glTexImage2D != nullptr && glDeleteTextures != nullptr && glPixelStorei != nullptr && glActiveTexture != nullptr; compressedTextureUploadAvailable = textureUploadAvailable && glCompressedTexImage2D != nullptr; + postProcessAvailable = + textureUploadAvailable && readyForSourcePatching && glViewport && glEnable && glDisable && + glIsEnabled && glBlendFunc && glClearColor && glClear && glDrawArrays && glGetFloatv && + glBindFramebuffer && glGenFramebuffers && glDeleteFramebuffers && glFramebufferTexture2D && + glCheckFramebufferStatus && glBlitFramebuffer && glGenVertexArrays && + glDeleteVertexArrays && glBindVertexArray && glGenBuffers && glDeleteBuffers && + glBindBuffer && glBufferData && glEnableVertexAttribArray && glVertexAttribPointer && + glBlendFuncSeparate; if (valid) { LOG_INFO( "OpenGL initialized (source patching={}, ARB shaders={}, texture upload={}, compressed " - "texture upload={})", + "texture upload={}, post process={})", readyForSourcePatching ? "ready" : "partial", arbShaderObjectsAvailable ? "ready" : "partial", textureUploadAvailable ? "ready" : "partial", - compressedTextureUploadAvailable ? "ready" : "partial"); + compressedTextureUploadAvailable ? "ready" : "partial", + postProcessAvailable ? "ready" : "partial"); LOG_DEBUG( "OpenGL entry points: glGetString={}, glTexParameteri={}, glTexParameterf={}, " "glGetIntegerv={}, glGetTexParameteriv={}, glGetError={}, glGenTextures={}, " diff --git a/src/iee/game/opengl_types.h b/src/iee/game/opengl_types.h index 8767b32..6fe9665 100644 --- a/src/iee/game/opengl_types.h +++ b/src/iee/game/opengl_types.h @@ -1,4 +1,5 @@ #pragma once +#include #include #ifdef _WIN32 @@ -34,6 +35,41 @@ constexpr unsigned RED = 0x1903; constexpr unsigned UNSIGNED_BYTE = 0x1401; constexpr unsigned UNPACK_ALIGNMENT = 0x0CF5; constexpr unsigned FRAMEBUFFER = 0x8D40; +constexpr unsigned READ_FRAMEBUFFER = 0x8CA8; +constexpr unsigned DRAW_FRAMEBUFFER = 0x8CA9; +constexpr unsigned DRAW_FRAMEBUFFER_BINDING = 0x8CA6; +constexpr unsigned READ_FRAMEBUFFER_BINDING = 0x8CAA; +constexpr unsigned COLOR_ATTACHMENT0 = 0x8CE0; +constexpr unsigned FRAMEBUFFER_COMPLETE = 0x8CD5; +constexpr unsigned RGBA16F = 0x881A; +constexpr unsigned HALF_FLOAT = 0x140B; +constexpr unsigned FLOAT = 0x1406; +constexpr unsigned VIEWPORT = 0x0BA2; +constexpr unsigned SCISSOR_TEST = 0x0C11; +constexpr unsigned BLEND = 0x0BE2; +constexpr unsigned DEPTH_TEST = 0x0B71; +constexpr unsigned CULL_FACE = 0x0B44; +constexpr unsigned STENCIL_TEST = 0x0B90; +constexpr unsigned ALPHA_TEST = 0x0BC0; +constexpr unsigned BLEND_DST_RGB = 0x80C8; +constexpr unsigned BLEND_SRC_RGB = 0x80C9; +constexpr unsigned BLEND_DST_ALPHA = 0x80CA; +constexpr unsigned BLEND_SRC_ALPHA = 0x80CB; +constexpr unsigned CURRENT_PROGRAM = 0x8B8D; +constexpr unsigned VERTEX_ARRAY_BINDING = 0x85B5; +constexpr unsigned ARRAY_BUFFER = 0x8892; +constexpr unsigned ARRAY_BUFFER_BINDING = 0x8894; +constexpr unsigned STATIC_DRAW = 0x88E4; +constexpr unsigned COLOR_CLEAR_VALUE = 0x0C22; +constexpr unsigned COLOR_BUFFER_BIT = 0x4000; +constexpr unsigned TRIANGLES = 0x0004; +constexpr unsigned ZERO = 0; +constexpr unsigned ONE = 1; +constexpr unsigned DST_COLOR = 0x0306; +constexpr unsigned FRAGMENT_SHADER = 0x8B30; +constexpr unsigned VERTEX_SHADER = 0x8B31; +constexpr unsigned COMPILE_STATUS = 0x8B81; +constexpr unsigned LINK_STATUS = 0x8B82; constexpr unsigned COMPRESSED_RGB_S3TC_DXT1_EXT = 0x83F0; constexpr unsigned COMPRESSED_RGBA_S3TC_DXT1_EXT = 0x83F1; constexpr unsigned COMPRESSED_RGBA_S3TC_DXT5_EXT = 0x83F3; @@ -92,12 +128,44 @@ using PFN_glUniform1f = void(APIENTRY*)(int location, float v0); using PFN_glUniform1i = void(APIENTRY*)(int location, int v0); using PFN_glUniform2f = void(APIENTRY*)(int location, float v0, float v1); using PFN_glUniform3f = void(APIENTRY*)(int location, float v0, float v1, float v2); +using PFN_glUniform4fv = void(APIENTRY*)(int location, int count, const float* value); using PFN_glActiveTexture = void(APIENTRY*)(unsigned texture); using PFN_glCompressedTexImage2D = void(APIENTRY*)(unsigned target, int level, unsigned internalformat, int width, int height, int border, int imageSize, const void* data); using PFN_glGenerateMipmap = void(APIENTRY*)(unsigned target); using PFN_glBindFramebuffer = void(APIENTRY*)(unsigned target, unsigned framebuffer); +using PFN_glViewport = void(APIENTRY*)(int x, int y, int width, int height); +using PFN_glEnable = void(APIENTRY*)(unsigned cap); +using PFN_glDisable = void(APIENTRY*)(unsigned cap); +using PFN_glIsEnabled = unsigned char(APIENTRY*)(unsigned cap); +using PFN_glBlendFunc = void(APIENTRY*)(unsigned sfactor, unsigned dfactor); +using PFN_glClearColor = void(APIENTRY*)(float r, float g, float b, float a); +using PFN_glClear = void(APIENTRY*)(unsigned mask); +using PFN_glDrawArrays = void(APIENTRY*)(unsigned mode, int first, int count); +using PFN_glGetFloatv = void(APIENTRY*)(unsigned pname, float* data); +using PFN_glGenFramebuffers = void(APIENTRY*)(int n, unsigned* framebuffers); +using PFN_glDeleteFramebuffers = void(APIENTRY*)(int n, const unsigned* framebuffers); +using PFN_glFramebufferTexture2D = void(APIENTRY*)(unsigned target, unsigned attachment, + unsigned textarget, unsigned texture, int level); +using PFN_glCheckFramebufferStatus = unsigned(APIENTRY*)(unsigned target); +using PFN_glBlitFramebuffer = void(APIENTRY*)(int srcX0, int srcY0, int srcX1, int srcY1, + int dstX0, int dstY0, int dstX1, int dstY1, + unsigned mask, unsigned filter); +using PFN_glGenVertexArrays = void(APIENTRY*)(int n, unsigned* arrays); +using PFN_glDeleteVertexArrays = void(APIENTRY*)(int n, const unsigned* arrays); +using PFN_glBindVertexArray = void(APIENTRY*)(unsigned array); +using PFN_glGenBuffers = void(APIENTRY*)(int n, unsigned* buffers); +using PFN_glDeleteBuffers = void(APIENTRY*)(int n, const unsigned* buffers); +using PFN_glBindBuffer = void(APIENTRY*)(unsigned target, unsigned buffer); +using PFN_glBufferData = void(APIENTRY*)(unsigned target, std::ptrdiff_t size, const void* data, + unsigned usage); +using PFN_glEnableVertexAttribArray = void(APIENTRY*)(unsigned index); +using PFN_glVertexAttribPointer = void(APIENTRY*)(unsigned index, int size, unsigned type, + unsigned char normalized, int stride, + const void* pointer); +using PFN_glBlendFuncSeparate = void(APIENTRY*)(unsigned srcRgb, unsigned dstRgb, + unsigned srcAlpha, unsigned dstAlpha); using PFN_glIsProgram = unsigned char(APIENTRY*)(unsigned program); using PFN_glShaderSourceARB = void(APIENTRY*)(unsigned shader, int count, const char* const* string, const int* length); @@ -123,6 +191,15 @@ struct OpenGLFunctions { PFN_glTexImage2D glTexImage2D{}; PFN_glDeleteTextures glDeleteTextures{}; PFN_glPixelStorei glPixelStorei{}; + PFN_glViewport glViewport{}; + PFN_glEnable glEnable{}; + PFN_glDisable glDisable{}; + PFN_glIsEnabled glIsEnabled{}; + PFN_glBlendFunc glBlendFunc{}; + PFN_glClearColor glClearColor{}; + PFN_glClear glClear{}; + PFN_glDrawArrays glDrawArrays{}; + PFN_glGetFloatv glGetFloatv{}; // Extensions PFN_glCreateShader glCreateShader{}; PFN_glShaderSource glShaderSource{}; @@ -144,10 +221,26 @@ struct OpenGLFunctions { PFN_glUniform1i glUniform1i{}; PFN_glUniform2f glUniform2f{}; PFN_glUniform3f glUniform3f{}; + PFN_glUniform4fv glUniform4fv{}; PFN_glActiveTexture glActiveTexture{}; PFN_glCompressedTexImage2D glCompressedTexImage2D{}; PFN_glGenerateMipmap glGenerateMipmap{}; PFN_glBindFramebuffer glBindFramebuffer{}; + PFN_glGenFramebuffers glGenFramebuffers{}; + PFN_glDeleteFramebuffers glDeleteFramebuffers{}; + PFN_glFramebufferTexture2D glFramebufferTexture2D{}; + PFN_glCheckFramebufferStatus glCheckFramebufferStatus{}; + PFN_glBlitFramebuffer glBlitFramebuffer{}; + PFN_glGenVertexArrays glGenVertexArrays{}; + PFN_glDeleteVertexArrays glDeleteVertexArrays{}; + PFN_glBindVertexArray glBindVertexArray{}; + PFN_glGenBuffers glGenBuffers{}; + PFN_glDeleteBuffers glDeleteBuffers{}; + PFN_glBindBuffer glBindBuffer{}; + PFN_glBufferData glBufferData{}; + PFN_glEnableVertexAttribArray glEnableVertexAttribArray{}; + PFN_glVertexAttribPointer glVertexAttribPointer{}; + PFN_glBlendFuncSeparate glBlendFuncSeparate{}; PFN_glIsProgram glIsProgram{}; PFN_glShaderSourceARB glShaderSourceARB{}; PFN_glCompileShaderARB glCompileShaderARB{}; @@ -163,6 +256,9 @@ struct OpenGLFunctions { bool arbShaderObjectsAvailable{false}; bool textureUploadAvailable{false}; bool compressedTextureUploadAvailable{false}; + // Everything the world post passes need: framebuffer objects, a vertex + // array, shaders with introspection, and the fixed-function state calls. + bool postProcessAvailable{false}; bool initialize() noexcept; }; diff --git a/src/iee/game/runtime_types_x64.h b/src/iee/game/runtime_types_x64.h index 19fa404..aca652f 100644 --- a/src/iee/game/runtime_types_x64.h +++ b/src/iee/game/runtime_types_x64.h @@ -71,6 +71,27 @@ namespace iee::game { std::array m_resRef{}; }; + // Parsed BAM resource (EEex docs, 176 bytes). The table pointers are set + // by CResCell::Parse on Demand and are only meaningful while the base + // resource is loaded. + struct CResCell { + CRes baseclass_0{}; + void *pUncompressedData{}; + std::uint32_t nUncompressedSize{}; + std::byte _pad0[4]{}; + bamHeader_st *m_pBamHeader{}; + BAMHEADERV2 *m_pBamHeaderV2{}; + void *m_pQuads{}; + frameTableEntry_st *m_pFrames{}; + sequenceTableEntry_st *m_pSequences{}; + std::uint16_t *m_pFrameList{}; + std::uint16_t m_nFrameList{}; // not set by CResCell::Parse; do not rely on it + std::byte _pad1[6]{}; + void *m_pPalette{}; + std::int32_t m_bParsing{}; + std::byte _pad2[4]{}; + }; + struct CResPVR { CRes baseclass_0{}; std::int32_t texture{}; @@ -145,7 +166,9 @@ namespace iee::game { struct CVidCell { void *vfptr{}; CVidImage baseclass_0{}; - std::array baseclass_1{}; + // CResHelper: the resource pointer, then its resref. + CResCell *pRes{}; + CResRef cResRef{}; std::int16_t m_nCurrentFrame{}; std::uint16_t m_nCurrentSequence{}; std::int32_t m_nAnimType{}; @@ -395,6 +418,66 @@ namespace iee::game { std::array _tail{}; }; + // Shared CGameObject header prefix of every area game object. Verified + // against the 2.6.6 decompilation (PDB-named); the full base is 0x60 + // bytes. See docs/are-animation-detection.md for the evidence trail. + struct CGameObject { + void *_vtable{}; + std::uint8_t m_objectType{}; + std::byte _pad0[3]{}; + CPoint m_pos{}; + std::int32_t m_posZ{}; + CGameArea *m_pArea{}; + std::array _tail{}; + }; + + // CParticle: a rain drop, snow flake or sparkle. Positions and velocities + // are fixed point; CParticle::AsynchronousUpdate adds the velocity to the + // position once per logic tick. Offsets from that function's 2.7.3 + // disassembly; only the leading fields are described. + struct CParticle { + std::int16_t m_nTimeStamp{}; + std::int16_t m_nTailLength{}; + std::byte _pad0[8]{}; + std::uint8_t m_wType{}; // bit 0: gravity only, no vertical velocity + std::byte _pad1[3]{}; + std::int32_t m_nLifeSpan{}; + std::byte _pad2[4]{}; + std::int32_t m_posX{}; + std::int32_t m_posY{}; + std::int32_t m_posZ{}; + std::int32_t m_velX{}; + std::int32_t m_velY{}; + std::int32_t m_velZ{}; + std::int32_t m_nGravity{}; + }; + + // CGameObject::m_objectType for ARE "static" ambient animations + // (script share type '0'; CGameStatic). + inline constexpr std::uint8_t kGameObjectTypeStatic = 0x30; + + // Runtime object created from each ARE animation record. The engine keeps + // the raw authored 76-byte record (CAreaFileStaticObject in the PDB, our + // ARE_Animation_st) embedded at +0x60; script state (e.g. the shown flag + // toggled by StaticStart) mutates it in place. m_vidCell::m_pFrame is + // only non-null during a draw (Render3d clears it on exit), so authored + // geometry is read from the BAM frame table behind m_vidCell::pRes. + struct CGameStatic { + CGameObject baseclass_0{}; + ARE_Animation_st m_header{}; + std::byte _pad0[4]{}; + CVidCell m_vidCell{}; + std::array _tail{}; + }; + + // CGameObjectArray's static entry table: object id in the low word, + // object pointer at +0x8, stride 16 (CGameObjectArray::GetShare). + struct CGameObjectArrayEntry { + std::int16_t m_objectId{}; + std::byte _pad0[6]{}; + CGameObject *m_objectPtr{}; + }; + struct CGameSprite { std::array _pad0{}; CResRef m_resref{}; @@ -453,6 +536,11 @@ namespace iee::game { static_assert(sizeof(CSize) == 0x8); static_assert(sizeof(CRect) == 0x10); static_assert(sizeof(CRes) == 0x58); + static_assert(sizeof(CResCell) == 0xB0); + static_assert(offsetof(CResCell, m_pBamHeader) == 0x68); + static_assert(offsetof(CResCell, m_pFrames) == 0x80); + static_assert(offsetof(CResCell, m_nFrameList) == 0x98); + static_assert(offsetof(CVidCell, pRes) == 0x108); static_assert(sizeof(CResRef) == 0x8); static_assert(sizeof(CResPVR) == 0x70); static_assert(sizeof(CResTileSet) == 0x60); @@ -498,6 +586,21 @@ namespace iee::game { static_assert(offsetof(CGameArea, m_visibility) == 0xBB0); static_assert(offsetof(CGameArea, m_lTiledObjects) == 0xED0); static_assert(offsetof(CGameArea, m_ptOldViewPos) == 0xF78); + static_assert(offsetof(CParticle, m_wType) == 0xC); + static_assert(offsetof(CParticle, m_posX) == 0x18); + static_assert(offsetof(CParticle, m_velX) == 0x24); + static_assert(offsetof(CParticle, m_nGravity) == 0x30); + static_assert(offsetof(CGameObject, m_objectType) == 0x8); + static_assert(offsetof(CGameObject, m_pos) == 0xC); + static_assert(offsetof(CGameObject, m_pArea) == 0x18); + static_assert(sizeof(CGameObject) == 0x60); + static_assert(offsetof(CGameStatic, m_header) == 0x60); + static_assert(offsetof(CGameStatic, m_vidCell) == 0xB0); + static_assert(offsetof(CVidCell, m_pFrame) == 0x128); + static_assert(sizeof(CVidCell) == 0x138); + static_assert(sizeof(CGameStatic) == 0x368); + static_assert(sizeof(CGameObjectArrayEntry) == 0x10); + static_assert(offsetof(CGameObjectArrayEntry, m_objectPtr) == 0x8); static_assert(offsetof(CGameSprite, m_resref) == 0x540); static_assert(offsetof(CGameSprite, m_currentArea) == 0x3A20); static_assert(offsetof(CGameSprite, m_spriteEffectVidCell) == 0x3C70); diff --git a/src/iee/game/sprite_motion.cpp b/src/iee/game/sprite_motion.cpp new file mode 100644 index 0000000..861b4f1 --- /dev/null +++ b/src/iee/game/sprite_motion.cpp @@ -0,0 +1,85 @@ +#include "sprite_motion.h" + +#include +#include +#include +#include + +namespace iee::game { +MotionPoint SpriteMotionTracker::shown(const State& state, double now) noexcept { + const double progress = std::clamp((now - state.changedAt) / state.interval, 0.0, 1.0); + const auto mix = [progress](std::int32_t from, std::int32_t to) { + return static_cast(std::lround(from + (to - from) * progress)); + }; + return {mix(state.from.x, state.to.x), mix(state.from.y, state.to.y)}; +} + +void SpriteMotionTracker::prune(double now) { + for (auto it = states_.begin(); it != states_.end();) { + it = now - it->second.lastSeen > kStaleSeconds ? states_.erase(it) : std::next(it); + } +} + +MotionPoint SpriteMotionTracker::sample(const void* key, MotionPoint current, double now) { + auto [it, inserted] = states_.try_emplace(key); + auto& state = it->second; + if (inserted) { + state = {current, current, now, kDefaultInterval, now}; + if (states_.size() > kPruneThreshold) prune(now); + return current; + } + + const bool forgotten = now - state.lastSeen > kForgetSeconds; + state.lastSeen = now; + if (forgotten) { + state = {current, current, now, kDefaultInterval, now}; + return current; + } + if (current != state.to) { + const auto jump = (std::max)(std::abs(current.x - state.to.x), std::abs(current.y - state.to.y)); + if (jump > snapDistance_) { + state.from = current; + } else { + // Start from what is on screen now so an early tick never jumps back. + state.from = shown(state, now); + const double observed = now - state.changedAt; + state.interval = + observed >= kMinInterval && observed <= kMaxInterval ? observed : kDefaultInterval; + } + state.to = current; + state.changedAt = now; + } + return shown(state, now); +} + +void TickClock::on_update(double now) noexcept { + const double last = lastUpdate_.exchange(now, std::memory_order_relaxed); + if (last >= 0.0 && now - last < kBurstSeconds && now >= last) return; + const double previousTick = tickStart_.exchange(now, std::memory_order_relaxed); + const double observed = now - previousTick; + interval_.store(previousTick >= 0.0 && observed >= kMinInterval && observed <= kMaxInterval + ? observed + : kDefaultInterval, + std::memory_order_relaxed); +} + +double TickClock::phase(double now) const noexcept { + const double start = tickStart_.load(std::memory_order_relaxed); + if (start < 0.0) return 1.0; + const double elapsed = (now - start) / interval_.load(std::memory_order_relaxed); + // Written so NaN lands on the logic position. + if (!(elapsed < 1.0)) return 1.0; + return elapsed > 0.0 ? elapsed : 0.0; +} + +ParticlePoint particle_draw_position(const ParticleState& particle, double phase) noexcept { + if (!(phase >= 0.0) || !(phase < 1.0)) return particle.position; + const double back = 1.0 - phase; + const auto step = [back](std::int32_t delta) { + return static_cast(std::lround(static_cast(delta) * back)); + }; + const auto heightStep = particle.gravityOnly ? -particle.gravity : particle.velocity.z; + return {particle.position.x - step(particle.velocity.x), + particle.position.y - step(particle.velocity.y), particle.position.z - step(heightStep)}; +} +} // namespace iee::game diff --git a/src/iee/game/sprite_motion.h b/src/iee/game/sprite_motion.h new file mode 100644 index 0000000..6a7e630 --- /dev/null +++ b/src/iee/game/sprite_motion.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include + +namespace iee::game { +struct MotionPoint { + std::int32_t x{}; + std::int32_t y{}; + + friend bool operator==(const MotionPoint&, const MotionPoint&) = default; +}; + +// Render-rate smoothing of positions that the engine only updates on its +// logic tick. Each key (a live sprite) is drawn sliding from where it was +// last shown to its newest logic position over one observed tick interval, +// so the picture trails the logic by at most one tick. +// +// Host-safe and single-threaded: the caller owns the clock and the thread. +class SpriteMotionTracker { + public: + explicit SpriteMotionTracker(std::int32_t snapDistance = kSnapDistance) noexcept + : snapDistance_(snapDistance) {} + + // Logic-tick interval assumed until two consecutive moves are observed. + static constexpr double kDefaultInterval = 1.0 / 30.0; + // Observed intervals outside this range are not a steady walk (first step + // after standing still, a hitch) and fall back to the default. + static constexpr double kMinInterval = 1.0 / 90.0; + static constexpr double kMaxInterval = 1.0 / 14.0; + // A larger single-tick move is a teleport or a reused object address and + // is shown immediately. Creatures use the default; fast movers (projectiles) + // pass a larger limit. + static constexpr std::int32_t kSnapDistance = 48; + // A key not sampled for this long was off screen or is a new object at a + // reused address: it starts over instead of sliding from a stale position. + static constexpr double kForgetSeconds = 0.5; + static constexpr std::size_t kPruneThreshold = 1024; + static constexpr double kStaleSeconds = 5.0; + + // `current` is the engine's position for `key` at monotonic time `now` + // (seconds). Returns the position to draw at. Repeated calls with the same + // arguments return the same result. + [[nodiscard]] MotionPoint sample(const void* key, MotionPoint current, double now); + + void clear() noexcept { states_.clear(); } + [[nodiscard]] std::size_t size() const noexcept { return states_.size(); } + + private: + struct State { + MotionPoint from{}; + MotionPoint to{}; + double changedAt{}; + double interval{kDefaultInterval}; + double lastSeen{}; + }; + + [[nodiscard]] static MotionPoint shown(const State& state, double now) noexcept; + void prune(double now); + + std::int32_t snapDistance_; + std::unordered_map states_; +}; + +// The engine's logic tick, as seen from any function it calls once (or many +// times in a burst) per tick. Things the engine advances by a known amount per +// tick can then be drawn part of the way through the step. on_update may run +// on a different thread from phase. +class TickClock { + public: + static constexpr double kDefaultInterval = 1.0 / 30.0; + static constexpr double kMinInterval = 1.0 / 90.0; + static constexpr double kMaxInterval = 1.0 / 14.0; + // Calls closer together than this belong to the same tick. + static constexpr double kBurstSeconds = 0.004; + + void on_update(double now) noexcept; + // 0 right after a tick, rising to 1 one tick interval later and staying + // there (so nothing drifts while the game is paused). 1 before any tick. + [[nodiscard]] double phase(double now) const noexcept; + + private: + std::atomic tickStart_{-1.0}; + std::atomic lastUpdate_{-1.0}; + std::atomic interval_{kDefaultInterval}; +}; + +struct ParticlePoint { + std::int32_t x{}; + std::int32_t y{}; + std::int32_t z{}; + friend bool operator==(const ParticlePoint&, const ParticlePoint&) = default; +}; + +// A CParticle after its per-tick update: the engine added `velocity` to the +// position (CParticle::AsynchronousUpdate). A gravity-only particle instead +// lost `gravity` in height. +struct ParticleState { + ParticlePoint position{}; + ParticlePoint velocity{}; + std::int32_t gravity{}; + bool gravityOnly{}; +}; + +// Where to draw a particle `phase` of the way through the current tick: its +// previous position at 0, its logic position at 1. +ParticlePoint particle_draw_position(const ParticleState& particle, double phase) noexcept; +} // namespace iee::game diff --git a/src/iee/game/texture_units.h b/src/iee/game/texture_units.h index fd08863..96ac3a4 100644 --- a/src/iee/game/texture_units.h +++ b/src/iee/game/texture_units.h @@ -9,5 +9,6 @@ inline constexpr unsigned AreaMask = 2; inline constexpr unsigned WaterNormal = 3; inline constexpr unsigned WaterDudv = 4; inline constexpr unsigned WaterFoam = 5; +inline constexpr unsigned EffectsNoise = 6; } // namespace iee::game::texture_units diff --git a/src/iee/game/tile_upscale.cpp b/src/iee/game/tile_upscale.cpp index 28cfc8a..693c981 100644 --- a/src/iee/game/tile_upscale.cpp +++ b/src/iee/game/tile_upscale.cpp @@ -81,19 +81,9 @@ std::optional infer_scale_from_tile_table(const TileInfo& return std::nullopt; } -bool is_upscaled_by_heuristics(const TileInfo& tileInfo, int textureId) { - if (!tileInfo.table || tileInfo.index < 0 || - static_cast(tileInfo.index) >= tileInfo.tileCount) { - return false; - } - - const auto& entry = tileInfo.entry; - return entry.u > UpscaleThresholds::UV_THRESHOLD || entry.v > UpscaleThresholds::UV_THRESHOLD || - textureId > UpscaleThresholds::TEXTURE_ID_THRESHOLD; -} - std::optional detect_scale(const TileInfo& tileInfo, int textureId, const BuildManifest& manifest) { + (void)textureId; if (auto headerDetection = detect_scale_from_tis_header(tileInfo, manifest)) { return headerDetection; } @@ -102,10 +92,6 @@ std::optional detect_scale(const TileInfo& tileInfo, int t return tableDetection; } - if (is_upscaled_by_heuristics(tileInfo, textureId)) { - return ScaleDetectionResult{4, ScaleDetectionSource::Heuristic, 0}; - } - return std::nullopt; } } // namespace iee::game diff --git a/src/iee/game/tile_upscale.h b/src/iee/game/tile_upscale.h index d9581e5..272cc13 100644 --- a/src/iee/game/tile_upscale.h +++ b/src/iee/game/tile_upscale.h @@ -7,8 +7,6 @@ namespace iee::game { namespace UpscaleThresholds { -constexpr int UV_THRESHOLD = 1024; -constexpr int TEXTURE_ID_THRESHOLD = 10000; constexpr int DETECTION_SAMPLE_COUNT = 10; } // namespace UpscaleThresholds @@ -22,7 +20,6 @@ constexpr std::uint32_t Upscaled8x = 0x200; enum class ScaleDetectionSource : std::uint8_t { TisHeader, TileTable, - Heuristic, }; struct ScaleDetectionResult { @@ -43,9 +40,13 @@ struct ScaleDetectionResult { [[nodiscard]] std::optional infer_scale_from_tile_table( const TileInfo& tileInfo); -[[nodiscard]] bool is_upscaled_by_heuristics(const TileInfo& tileInfo, int textureId); - -// Production scale selection: deterministic metadata first, heuristic last. +// Production scale selection: deterministic metadata only (TIS header, then +// PVR entry-table coordinate grid). When neither resolves, nullopt fails +// closed into the caller's sampling path, which delegates the tileset to the +// engine as standard 1x. Raw UV magnitudes and GL texture ids are not valid +// scale signals (atlas origins are arbitrary; texture ids grow with session +// allocations) — the former heuristic built on them produced false 4x +// detections on vanilla areas and was removed. [[nodiscard]] std::optional detect_scale(const TileInfo& tileInfo, int textureId, const BuildManifest& manifest); diff --git a/src/iee/game/world_post_plan.cpp b/src/iee/game/world_post_plan.cpp new file mode 100644 index 0000000..42d827f --- /dev/null +++ b/src/iee/game/world_post_plan.cpp @@ -0,0 +1,64 @@ +#include "iee/game/world_post_plan.h" + +#include +#include + +namespace iee::game { +namespace { +constexpr int kMinBlurExtent = 8; +constexpr int kMaxBloomLevels = 5; +} // namespace + +Extent half_extent(Extent extent) noexcept { + return {std::max(1, extent.width / 2), std::max(1, extent.height / 2)}; +} + +Region untextured_corner(Extent upscaled) noexcept { + // The 2x2 block doubled, plus two source texels of kernel reach. + constexpr int kSide = 8; + const int width = std::clamp(upscaled.width, 0, kSide); + const int height = std::clamp(upscaled.height, 0, kSide); + return {std::max(0, upscaled.width) - width, std::max(0, upscaled.height) - height, width, + height}; +} + +int available_blur_levels(Extent base) noexcept { + int levels = 0; + Extent extent = base; + while (levels < kMaxBlurLevels) { + extent = half_extent(extent); + if (std::min(extent.width, extent.height) < kMinBlurExtent) break; + ++levels; + } + return levels; +} + +BlurPlan blur_plan_for_radius(float radiusPixels, Extent base) noexcept { + // Written so NaN fails the test. + if (!(radiusPixels >= 1.0f)) return {}; + const int available = available_blur_levels(base); + if (available == 0) return {}; + int levels = 1; + while (levels < available && static_cast(1 << (levels + 1)) < radiusPixels) ++levels; + const auto reach = static_cast(1 << (levels + 1)); + return {levels, std::clamp(radiusPixels / reach, 0.5f, 1.5f)}; +} + +int bloom_levels(Extent base) noexcept { + return std::min(kMaxBloomLevels, available_blur_levels(base)); +} + +float temporal_blend(float stepSeconds, float smoothingSeconds) noexcept { + constexpr float kStaleSeconds = 0.5f; + if (!(smoothingSeconds > 0.0f)) return 1.0f; + // Written so NaN drops the history. + if (!(stepSeconds >= 0.0f) || stepSeconds > kStaleSeconds) return 1.0f; + return 1.0f - std::exp(-stepSeconds / smoothingSeconds); +} + +float pixels_per_world_pixel(float viewportWidth, float viewWorldWidth) noexcept { + if (!std::isfinite(viewportWidth) || !std::isfinite(viewWorldWidth)) return 1.0f; + if (!(viewportWidth > 0.0f) || !(viewWorldWidth > 0.0f)) return 1.0f; + return std::clamp(viewportWidth / viewWorldWidth, 0.25f, 8.0f); +} +} // namespace iee::game diff --git a/src/iee/game/world_post_plan.h b/src/iee/game/world_post_plan.h new file mode 100644 index 0000000..b2efaba --- /dev/null +++ b/src/iee/game/world_post_plan.h @@ -0,0 +1,75 @@ +#pragma once + +namespace iee::game { +// Sizing for the world post-processing passes (soft fog of war, bloom). Pure +// arithmetic so it can be tested without a GL context. + +struct Extent { + int width{}; + int height{}; + friend bool operator==(const Extent&, const Extent&) = default; +}; + +struct Region { + int x{}; + int y{}; + int width{}; + int height{}; + friend bool operator==(const Region&, const Region&) = default; +}; + +inline constexpr int kMaxBlurLevels = 6; + +// A dual-filter blur: `levels` successive half-size images below the source, +// sampled with `offset` texels of spread. levels == 0 means "do not blur". +struct BlurPlan { + int levels{}; + float offset{1.0f}; +}; + +Extent half_extent(Extent extent) noexcept; +// How many half-size levels fit below `base` before a side drops under 8 px. +int available_blur_levels(Extent base) noexcept; +// The plan whose blur reaches about `radiusPixels` on an image of `base` size. +BlurPlan blur_plan_for_radius(float radiusPixels, Extent base) noexcept; +int bloom_levels(Extent base) noexcept; +// The engine has no untextured draw: selection circles, health bars and other +// plain-coloured shapes sample a 2x2 white block it keeps in the last rows and +// columns of sprite atlas 0 (written once in DrawInit_GL, addressed at +// coordinate (1, 1)). This is that block, with room for the filter kernels, +// in a 2x copy of the atlas. +Region untextured_corner(Extent upscaled) noexcept; +// Tells world draws from UI draws by where a frame is. Within a frame that +// shows an area, everything queued up to the fog of war is world and everything +// after it is UI; a frame that shows no area (menus, full-screen panels) is +// all UI. The frame boundary may be reported more than once per presented +// frame, so "an area was drawn recently" is counted in boundaries, not exact. +class WorldPassGate { + public: + void on_frame() noexcept { + if (sinceArea_ < kNever) ++sinceArea_; + closedForFrame_ = false; + } + // The fog of war is about to be drawn: the world part of this frame is over. + void on_area_drawn() noexcept { + sinceArea_ = 0; + closedForFrame_ = true; + } + [[nodiscard]] bool open() const noexcept { + return !closedForFrame_ && sinceArea_ <= kTolerance; + } + + private: + static constexpr int kTolerance = 4; + static constexpr int kNever = 1000; + int sinceArea_{kNever}; + bool closedForFrame_{}; +}; + +// How far to move the shown image towards the new one after `stepSeconds`, +// for an exponential approach with time constant `smoothingSeconds`. 1 means +// "show the new image": no smoothing, or a history too old to trust. +float temporal_blend(float stepSeconds, float smoothingSeconds) noexcept; +// Screen pixels per world pixel at the current zoom; 1 when either is unknown. +float pixels_per_world_pixel(float viewportWidth, float viewWorldWidth) noexcept; +} // namespace iee::game diff --git a/src/iee/hooks.cpp b/src/iee/hooks.cpp index b995af1..30175ab 100644 --- a/src/iee/hooks.cpp +++ b/src/iee/hooks.cpp @@ -3,9 +3,15 @@ #include #include +#include #include +#include #include +#include #include +#include +#include +#include #include "app_context.h" #include "area_state.h" @@ -14,15 +20,21 @@ #include "iee/core/pattern_scanner.h" #include "iee/core/performance_samples.h" #include "iee/features/tile_render.h" +#include "iee/features/world_post.h" #include "iee/frame_hook.h" #include "iee/game/game_types.h" +#include "iee/game/animation_interp.h" +#include "iee/game/draw_queue.h" #include "iee/game/renderer.h" +#include "iee/game/runtime_types_x64.h" +#include "iee/game/sprite_motion.h" #include "iee/shader_probe.h" namespace iee::hooks { using LoadAreaFn = void* (*)(void*, void*, unsigned char, unsigned char, unsigned char); using RenderTextureFn = void (*)(void*, int, void*, int, int, unsigned long); using DrawColorToneFn = void (*)(int); +using StaticRenderFn = void (*)(void*, void*, void*); // Hook management - initialize MinHook // Intentionally explicit lifetime: a static smart-pointer destructor would @@ -31,8 +43,52 @@ static core::HookInit* g_hookInit = nullptr; static core::Hook g_loadAreaHook; static core::Hook g_renderTextureHook; static core::Hook g_drawColorToneHook; +static core::Hook g_staticRenderHook; +// CGameSprite::Render(area, vidMode) and the (vidMode) overlay renders share +// one pass-through signature; the unused third argument is harmless on x64. +using SpriteRenderFn = void (*)(void*, void*, void*); +static core::Hook g_spriteRenderHook; +static core::Hook g_spriteMarkersHook; +static core::Hook g_spriteHealthBarHook; +static std::array, game::kMaxSmoothedObjectRenders> g_objectRenderHooks; +// CVidCell::GetFrame(), GetCurrentCenterPoint(CPoint&), GetCurrentFrameSize(CSize&): +// one pass-through signature, the unused argument is harmless on x64. +using VidCellAccessorFn = int (*)(void*, void*); +static core::Hook g_vidCellGetFrameHook; +static core::Hook g_vidCellCenterPointHook; +static core::Hook g_vidCellFrameSizeHook; +// CInfinity::RenderFog(CVidMode*, CVisibilityMap*) and the GL backend's +// argument-less queue flush, used together by the world post passes. +using RenderFogFn = void (*)(void*, void*, void*); +using DrawFlushFn = void (*)(); +static core::Hook g_renderFogHook; +static DrawFlushFn g_drawFlush = nullptr; +// DrawFlush_GL is also detoured, to replay the queue's additive draws into +// the bloom's emissive target. g_drawFlush stays the engine's entry point, so +// our own flushes go through the detour too. +static core::Hook g_drawFlushHook; +static std::int32_t* g_drawQueueCount = nullptr; +static game::DrawCommand* g_drawQueueCommands = nullptr; +// TexSubImage_GL(x, y, width, rows, pixels, secondTexture): the engine's +// texture upload, hooked for the sprite atlas. +using TextureUploadFn = void (*)(int, int, int, int, void*, bool); +static core::Hook g_textureUploadHook; +// Atlas textures replaced by their upscaled copies for the current flush. +struct SwappedAtlas { + std::uint32_t* name{}; + std::uint32_t original{}; +}; +static std::array g_swappedAtlases{}; + +// CParticle::AsynchronousUpdate() and CParticle::Render(CPoint&, CRect&, type, n). +using ParticleUpdateFn = unsigned char (*)(void*); +using ParticleRenderFn = void (*)(void*, void*, void*, unsigned short, unsigned short); +static core::Hook g_particleUpdateHook; +static core::Hook g_particleRenderHook; static AppContext* g_ctx = nullptr; +// Raised by LoadArea; the sprite-smoothing tracker clears itself on its own thread. +static std::atomic g_spriteMotionReset{false}; namespace { void record_render_performance(bool enabled, bool handled, long long elapsedTicks) noexcept { @@ -207,6 +263,7 @@ void publish_view_state(bool force = false, bool flushGpuUpload = true) { area::refresh_wed_cache(*g_ctx, infGame); } } + area::republish_area_animations_if_dirty(*g_ctx); if (!g_ctx->wed.load()) { return; } @@ -228,12 +285,15 @@ static void* detour_load_area(void* thisPtr, void* pAreaNameString, unsigned cha auto& ctx = *g_ctx; try { core::advance_readability_cache_epoch(); - LOG_DEBUG("LoadArea called - resetting scale detection for new area"); + LOG_INFO("LoadArea called on thread {} - resetting scale detection for new area", + GetCurrentThreadId()); ctx.infGame.store(thisPtr, std::memory_order_relaxed); // Invalidate any older refresh before clearing its published CPU state. area::reset_gpu_area_state(); ctx.reset_area_state(); features::request_tile_render_state_reset(); + g_spriteMotionReset.store(true, std::memory_order_release); + features::world_post_on_area_load(); game::request_texture_configuration_cache_reset(); } catch (const std::exception& e) { LOG_ERROR("LoadArea pre-dispatch failed; continuing with the engine path: {}", e.what()); @@ -275,6 +335,299 @@ static void detour_draw_color_tone(int mode) { g_drawColorToneHook.original()(mode); } +// Sprite movement smoothing. The engine moves creatures on its logic tick +// (30 Hz) while EEex's uncapped renderer draws many frames per tick, so every +// render that reads the sprite position is wrapped: the smoothed position is +// written into CGameObject::m_pos for the duration of the engine call and the +// logic position is restored afterwards. All of it runs on the engine's main +// thread; LoadArea only raises the reset flag. +static game::SpriteMotionTracker g_spriteMotion; +// Projectiles and effects cover far more ground per tick than creatures. +static game::SpriteMotionTracker g_objectMotion{192}; +// Draw-time animation interpolation state (see detour_sprite_render). +static game::AnimationInterpolator g_animationInterp; +static bool g_animationInterpActive = false; +static int g_spritePositionSwapDepth = 0; + +// One timestamp per presented frame, so a sprite, its selection circle and +// its health bar are all placed at the same smoothed position. +static double sprite_motion_frame_seconds() noexcept { + static unsigned long long lastFrame = ~0ull; + static double seconds = 0.0; + const auto frameNumber = frame::frame_count(); + if (frameNumber != lastFrame || frameNumber == 0) { + lastFrame = frameNumber; + static const double frequency = [] { + LARGE_INTEGER value{}; + QueryPerformanceFrequency(&value); + return static_cast(value.QuadPart); + }(); + LARGE_INTEGER counter{}; + QueryPerformanceCounter(&counter); + seconds = static_cast(counter.QuadPart) / frequency; + } + return seconds; +} + +static void call_with_smoothed_position(core::Hook& hook, + game::SpriteMotionTracker& tracker, void* sprite, + void* a, void* b) { + const auto original = hook.original(); + // Nested sprite renders already see the smoothed position. + if (!sprite || !g_ctx || g_spritePositionSwapDepth > 0 || !g_ctx->cfg.smoothSpriteMovement) { + original(sprite, a, b); + return; + } + auto* position = reinterpret_cast(static_cast(sprite) + + offsetof(game::CGameObject, m_pos)); + game::CPoint logic{}; + std::int32_t shownX = 0; + std::int32_t shownY = 0; + bool swapped = false; + try { + static bool threadLogged = false; + if (!threadLogged) { + threadLogged = true; + LOG_INFO("Sprite smoothing: first sprite render on thread {}", GetCurrentThreadId()); + } + if (g_spriteMotionReset.exchange(false, std::memory_order_acq_rel)) { + g_spriteMotion.clear(); + g_objectMotion.clear(); + g_animationInterp.reset_timing(); + } + if (core::safe_read(position, logic)) { + const auto shown = + tracker.sample(sprite, {logic.x, logic.y}, sprite_motion_frame_seconds()); + if (shown.x != logic.x || shown.y != logic.y) { + shownX = shown.x; + shownY = shown.y; + position->x = shownX; + position->y = shownY; + swapped = true; + } + } + } catch (...) { + // Smoothing is cosmetic; any doubt draws at the logic position. + } + ++g_spritePositionSwapDepth; + original(sprite, a, b); + --g_spritePositionSwapDepth; + // Restore only our own write: if the engine moved the sprite meanwhile, its + // value is the newer logic position and must win. + if (swapped && position->x == shownX && position->y == shownY) *position = logic; +} + +// Animation interpolation. While a creature is being drawn, every CVidCell the +// engine resolves a frame for is pointed at its expanded BAM and the in-between +// frame for this instant (see game::AnimationInterpolator); the logic's +// resource and frame are restored when the draw returns. Main thread only. + +static void detour_sprite_render(void* sprite, void* a, void* b) { + if (!g_animationInterpActive) { + call_with_smoothed_position(g_spriteRenderHook, g_spriteMotion, sprite, a, b); + return; + } + g_animationInterp.begin_scope(sprite); + call_with_smoothed_position(g_spriteRenderHook, g_spriteMotion, sprite, a, b); + g_animationInterp.end_scope(); +} +static int detour_vid_cell_get_frame(void* cell, void* unused) { + g_animationInterp.touch(static_cast(cell), sprite_motion_frame_seconds()); + return g_vidCellGetFrameHook.original()(cell, unused); +} +static int detour_vid_cell_center_point(void* cell, void* out) { + g_animationInterp.touch(static_cast(cell), sprite_motion_frame_seconds()); + return g_vidCellCenterPointHook.original()(cell, out); +} +static int detour_vid_cell_frame_size(void* cell, void* out) { + g_animationInterp.touch(static_cast(cell), sprite_motion_frame_seconds()); + return g_vidCellFrameSizeHook.original()(cell, out); +} +// Particle smoothing (rain, snow, sparkles). The engine adds each particle's +// velocity to its position once per logic tick, so between ticks its previous +// position is known exactly. Drawing it part of the way from there removes +// the stepping. The update may run on another thread: the tick clock is +// atomic, and the position is only put back if the engine has not moved the +// particle in the meantime. +static game::TickClock g_particleTick; + +static double monotonic_seconds() noexcept { + static const double frequency = [] { + LARGE_INTEGER value{}; + QueryPerformanceFrequency(&value); + return static_cast(value.QuadPart); + }(); + LARGE_INTEGER counter{}; + QueryPerformanceCounter(&counter); + return static_cast(counter.QuadPart) / frequency; +} + +static unsigned char detour_particle_update(void* particle) { + g_particleTick.on_update(monotonic_seconds()); + return g_particleUpdateHook.original()(particle); +} + +static void detour_particle_render(void* self, void* origin, void* clip, unsigned short type, + unsigned short count) { + const auto original = g_particleRenderHook.original(); + auto* particle = static_cast(self); + const double phase = g_particleTick.phase(sprite_motion_frame_seconds()); + if (!particle || phase >= 1.0) { + original(self, origin, clip, type, count); + return; + } + const game::ParticlePoint logic{particle->m_posX, particle->m_posY, particle->m_posZ}; + const auto shown = game::particle_draw_position( + {logic, + {particle->m_velX, particle->m_velY, particle->m_velZ}, + particle->m_nGravity, + (particle->m_wType & 1) != 0}, + phase); + particle->m_posX = shown.x; + particle->m_posY = shown.y; + particle->m_posZ = shown.z; + original(self, origin, clip, type, count); + if (particle->m_posX == shown.x && particle->m_posY == shown.y && particle->m_posZ == shown.z) { + particle->m_posX = logic.x; + particle->m_posY = logic.y; + particle->m_posZ = logic.z; + } +} + +static void detour_sprite_markers(void* sprite, void* a, void* b) { + call_with_smoothed_position(g_spriteMarkersHook, g_spriteMotion, sprite, a, b); +} +static void detour_sprite_health_bar(void* sprite, void* a, void* b) { + call_with_smoothed_position(g_spriteHealthBarHook, g_spriteMotion, sprite, a, b); +} + +// One detour per manifest slot, so each knows which original to call. +template +static void detour_object_render(void* object, void* a, void* b) { + call_with_smoothed_position(g_objectRenderHooks[Slot], g_objectMotion, object, a, b); +} +template +static constexpr std::array object_render_detours( + std::index_sequence) { + return {&detour_object_render...}; +} +static constexpr auto kObjectRenderDetours = + object_render_detours(std::make_index_sequence{}); + +// Bloom source. The engine draws its light-emitting art (fires, spell +// effects, glows) additively, and every queued command records its blend +// mode. During the world pass each flush is followed by a second one that +// holds only those commands, with our black emissive target bound. The vertex +// data and textures of the first flush are still in place: the engine only +// overwrites them when new draws are queued. Render thread only. +static void flush_with_emissive_replay() { + const auto original = g_drawFlushHook.original(); + if (!g_ctx || !g_drawQueueCount || !features::world_post_wants_emissive()) { + original(); + return; + } + static std::array additive; + const auto collected = game::collect_additive(g_ctx->manifest->drawQueue, g_drawQueueCommands, + *g_drawQueueCount, additive); + original(); + if (collected == 0 || !features::world_post_begin_emissive()) return; + std::memcpy(g_drawQueueCommands, additive.data(), collected * sizeof(game::DrawCommand)); + *g_drawQueueCount = static_cast(collected); + original(); + features::world_post_end_emissive(static_cast(collected)); +} + +static void detour_draw_flush() { + flush_with_emissive_replay(); + // The engine uploads into, and binds, whatever name its texture table + // holds: give it its own atlas textures back before the next upload. + for (auto& swapped : g_swappedAtlases) { + if (swapped.name) *swapped.name = swapped.original; + swapped = {}; + } +} + +// Sprite smoothing. A flush starts by uploading the CPU-composited sprite +// atlas, then draws from it. Right after that upload the atlas is upscaled +// (FSR1) into our own texture, whose name replaces the atlas's in the engine's +// texture table until the flush is over. The upload has just marked the +// texture as needing a rebind, so the first draw picks ours up. +static void detour_texture_upload(int x, int y, int width, int rows, void* pixels, bool second) { + g_textureUploadHook.original()(x, y, width, rows, pixels, second); + if (!g_ctx || !g_drawQueueCount) return; + const auto& layout = g_ctx->manifest->spriteAtlas; + const auto count = reinterpret_cast(g_drawQueueCount); + if (second || x != 0 || y != 0 || !features::world_post_wants_atlas_upscale()) return; + const int slot = game::atlas_slot_for_upload(layout, count, pixels); + if (slot < 0 || static_cast(slot) >= g_swappedAtlases.size()) return; + auto& swapped = g_swappedAtlases[static_cast(slot)]; + const auto atlas = game::atlas_info(layout, count, slot); + if (!atlas.textureName || swapped.name || atlas.width != width) return; + const unsigned upscaled = features::world_post_upscale_atlas(slot, *atlas.textureName, + atlas.width, atlas.height, rows); + if (!upscaled) return; + swapped = {atlas.textureName, *atlas.textureName}; + *atlas.textureName = upscaled; +} + +// World post passes (soft fog of war, bloom). The engine queues every draw +// and submits the queue in DrawFlush_GL at the end of the frame, so the world +// only exists in the framebuffer once we flush, and the fog only lands in our +// target if we flush again while it is bound. With both effects off this +// detour is a plain pass-through: no flush, the frame is the engine's own. +static void detour_render_fog(void* infinity, void* vidMode, void* visibility) { + const auto original = g_renderFogHook.original(); + if (g_ctx && g_ctx->cfg.enableDebugHotkeys) features::world_post_poll_hotkeys(); + if (!g_ctx || !g_drawFlush || !features::world_post_active()) { + original(infinity, vidMode, visibility); + return; + } + features::WorldView worldView{}; + if (const auto* activeArea = g_ctx->activeArea.load()) { + area::ViewTransform view{}; + if (area::read_view_transform(activeArea, view)) { + worldView = {view.scrollX, view.scrollY, view.viewWorldW, view.viewWorldH}; + } + } + g_drawFlush(); + const bool capturing = features::world_post_before_fog(worldView); + original(infinity, vidMode, visibility); + if (capturing) { + g_drawFlush(); + features::world_post_after_fog(); + } +} + +// CGameStatic::Render hook: while the fpSEAM point effects are active, the +// authored fire/smoke BAM draws are replaced by our textured effects, so the +// engine's own little flame/puff loops are skipped. Everything else (lights, +// wildlife, WBM/PVRZ setpieces, unclassified overlays) renders vanilla. +static void detour_static_render(void* thisPtr, void* area, void* vidMode) { + try { + if (g_ctx && g_ctx->cfg.enablePointEffects && g_ctx->cfg.enableWaterEffect && + probe::override_effect_replacement_enabled() && thisPtr) { + game::ARE_Animation_st header{}; + const auto* headerAddress = + reinterpret_cast(thisPtr) + offsetof(game::CGameStatic, m_header); + if (core::safe_read(headerAddress, header) && + (header.nFlags & + (game::kAreAnimationFlagUseWbm | game::kAreAnimationFlagUsePvrz)) == 0) { + const auto info = game::make_area_animation_info(header); + // Suppress only once the authored draw box is known: the engine's + // first draw loads the BAM that box is read from, and the point then + // lands exactly where RenderBam would have drawn. + if (game::should_replace_animation_draw(info.resrefView(), info.kind) && + area::static_envelope_ready(thisPtr)) { + return; // replaced by the shader's point effects + } + } + } + } catch (...) { + // Suppression is cosmetic; any doubt falls through to the engine draw. + } + g_staticRenderHook.original()(thisPtr, area, vidMode); +} + // RenderTexture hook - thin dispatch into the tile upscale feature static void detour_render_texture(void* thisPtr, int texId, void* unused, int x, int y, unsigned long flags) { @@ -350,6 +703,146 @@ bool install_all(AppContext& ctx) { "transform cannot be published safely. Tile upscaling remains enabled."); } + if (ctx.addrs.StaticRender) { + try { + g_staticRenderHook.create(reinterpret_cast(ctx.addrs.StaticRender), + reinterpret_cast(&detour_static_render)); + g_staticRenderHook.enable(); + LOG_INFO("CGameStatic::Render hook installed (fire/smoke BAM replacement)"); + } catch (const std::exception& e) { + LOG_WARN("CGameStatic::Render hook failed ({}); authored fire/smoke draws stay vanilla", + e.what()); + } catch (...) { + LOG_WARN("CGameStatic::Render hook failed; authored fire/smoke draws stay vanilla"); + } + } + + if ((ctx.cfg.smoothSpriteMovement || ctx.cfg.interpolateAnimations) && + ctx.addrs.SpriteRender && ctx.addrs.SpriteRenderMarkers && + ctx.addrs.SpriteRenderHealthBar) { + try { + g_spriteRenderHook.create(reinterpret_cast(ctx.addrs.SpriteRender), + reinterpret_cast(&detour_sprite_render)); + g_spriteMarkersHook.create(reinterpret_cast(ctx.addrs.SpriteRenderMarkers), + reinterpret_cast(&detour_sprite_markers)); + g_spriteHealthBarHook.create(reinterpret_cast(ctx.addrs.SpriteRenderHealthBar), + reinterpret_cast(&detour_sprite_health_bar)); + g_spriteRenderHook.enable(); + g_spriteMarkersHook.enable(); + g_spriteHealthBarHook.enable(); + LOG_INFO("Sprite render hooks installed (movement smoothing={})", + ctx.cfg.smoothSpriteMovement); + if (ctx.cfg.interpolateAnimations && ctx.addrs.VidCellGetFrame && + ctx.addrs.VidCellGetCurrentCenterPoint && ctx.addrs.VidCellGetCurrentFrameSize) { + try { + wchar_t executablePath[MAX_PATH]{}; + const auto length = GetModuleFileNameW(nullptr, executablePath, MAX_PATH); + if (length == 0 || length >= MAX_PATH) throw std::runtime_error("no game path"); + const auto directory = + std::filesystem::path(executablePath).parent_path() / "iee-interp"; + g_animationInterp.set_directory(directory); + g_vidCellGetFrameHook.create(reinterpret_cast(ctx.addrs.VidCellGetFrame), + reinterpret_cast(&detour_vid_cell_get_frame)); + g_vidCellCenterPointHook.create( + reinterpret_cast(ctx.addrs.VidCellGetCurrentCenterPoint), + reinterpret_cast(&detour_vid_cell_center_point)); + g_vidCellFrameSizeHook.create( + reinterpret_cast(ctx.addrs.VidCellGetCurrentFrameSize), + reinterpret_cast(&detour_vid_cell_frame_size)); + g_vidCellGetFrameHook.enable(); + g_vidCellCenterPointHook.enable(); + g_vidCellFrameSizeHook.enable(); + g_animationInterpActive = true; + LOG_INFO("Animation interpolation hooks installed (expanded BAMs from {})", + directory.string()); + } catch (...) { + g_animationInterpActive = false; + (void)g_vidCellFrameSizeHook.remove(); + (void)g_vidCellCenterPointHook.remove(); + (void)g_vidCellGetFrameHook.remove(); + LOG_WARN("Animation interpolation hooks failed; animations play their original " + "frames"); + } + } + } catch (...) { + (void)g_spriteHealthBarHook.remove(); + (void)g_spriteMarkersHook.remove(); + (void)g_spriteRenderHook.remove(); + LOG_WARN("Sprite movement smoothing hooks failed; creatures move at the logic rate"); + } + } + + if (ctx.cfg.smoothSpriteMovement) { + std::size_t installed = 0; + for (std::size_t slot = 0; slot < g_objectRenderHooks.size(); ++slot) { + const auto target = ctx.addrs.SmoothedObjectRenders[slot]; + if (!target) continue; + try { + g_objectRenderHooks[slot].create(reinterpret_cast(target), + reinterpret_cast(kObjectRenderDetours[slot])); + g_objectRenderHooks[slot].enable(); + ++installed; + } catch (...) { + (void)g_objectRenderHooks[slot].remove(); + LOG_WARN("Object movement smoothing hook {} failed; that object type moves at the " + "logic rate", + ctx.manifest->smoothedObjectRenders[slot].name); + } + } + LOG_INFO("Object movement smoothing hooks installed: {}", installed); + + if (ctx.addrs.ParticleUpdate && ctx.addrs.ParticleRender) { + try { + g_particleUpdateHook.create(reinterpret_cast(ctx.addrs.ParticleUpdate), + reinterpret_cast(&detour_particle_update)); + g_particleRenderHook.create(reinterpret_cast(ctx.addrs.ParticleRender), + reinterpret_cast(&detour_particle_render)); + g_particleUpdateHook.enable(); + g_particleRenderHook.enable(); + LOG_INFO("Particle movement smoothing hooks installed (rain, snow, sparkles)"); + } catch (...) { + (void)g_particleRenderHook.remove(); + (void)g_particleUpdateHook.remove(); + LOG_WARN("Particle movement smoothing hooks failed; particles move at the logic rate"); + } + } + } + + if (ctx.addrs.RenderFog && ctx.addrs.DrawFlush) { + try { + features::world_post_configure(ctx.cfg); + g_drawFlush = reinterpret_cast(ctx.addrs.DrawFlush); + g_renderFogHook.create(reinterpret_cast(ctx.addrs.RenderFog), + reinterpret_cast(&detour_render_fog)); + g_renderFogHook.enable(); + LOG_INFO("CInfinity::RenderFog hook installed (soft fog of war={}, bloom={})", + ctx.cfg.softFogOfWar, ctx.cfg.bloom); + if (ctx.addrs.DrawQueueCount && ctx.addrs.DrawQueueCommands) { + g_drawQueueCount = reinterpret_cast(ctx.addrs.DrawQueueCount); + g_drawQueueCommands = reinterpret_cast(ctx.addrs.DrawQueueCommands); + g_drawFlushHook.create(reinterpret_cast(ctx.addrs.DrawFlush), + reinterpret_cast(&detour_draw_flush)); + g_drawFlushHook.enable(); + LOG_INFO("DrawFlush_GL hook installed (bloom from the engine's additive draws)"); + if (ctx.addrs.TextureUpload) { + g_textureUploadHook.create(reinterpret_cast(ctx.addrs.TextureUpload), + reinterpret_cast(&detour_texture_upload)); + g_textureUploadHook.enable(); + LOG_INFO("TexSubImage_GL hook installed at 0x{:X} (sprite upscaling={})", + ctx.addrs.TextureUpload, ctx.cfg.spriteUpscale); + } + } + } catch (...) { + (void)g_textureUploadHook.remove(); + g_drawQueueCount = nullptr; + g_drawQueueCommands = nullptr; + (void)g_drawFlushHook.remove(); + g_drawFlush = nullptr; + (void)g_renderFogHook.remove(); + LOG_WARN("CInfinity::RenderFog hook failed; fog of war and bloom stay vanilla"); + } + } + g_loadAreaHook.enable(); LOG_INFO("LoadArea hook enabled"); @@ -364,6 +857,7 @@ bool install_all(AppContext& ctx) { return true; } catch (const std::exception& e) { LOG_ERROR("Exception during hook installation: {}", e.what()); + (void)g_staticRenderHook.remove(); (void)g_drawColorToneHook.remove(); (void)g_renderTextureHook.remove(); (void)g_loadAreaHook.remove(); @@ -373,6 +867,7 @@ bool install_all(AppContext& ctx) { return false; } catch (...) { LOG_ERROR("Unknown exception during hook installation"); + (void)g_staticRenderHook.remove(); (void)g_drawColorToneHook.remove(); (void)g_renderTextureHook.remove(); (void)g_loadAreaHook.remove(); @@ -389,6 +884,24 @@ void uninstall_all() noexcept { } catch (...) { } + (void)g_textureUploadHook.remove(); + (void)g_drawFlushHook.remove(); + g_drawQueueCount = nullptr; + g_drawQueueCommands = nullptr; + (void)g_renderFogHook.remove(); + g_drawFlush = nullptr; + features::world_post_forget(); + g_animationInterpActive = false; + (void)g_vidCellFrameSizeHook.remove(); + (void)g_vidCellCenterPointHook.remove(); + (void)g_vidCellGetFrameHook.remove(); + (void)g_particleRenderHook.remove(); + (void)g_particleUpdateHook.remove(); + for (auto& hook : g_objectRenderHooks) (void)hook.remove(); + (void)g_spriteHealthBarHook.remove(); + (void)g_spriteMarkersHook.remove(); + (void)g_spriteRenderHook.remove(); + (void)g_staticRenderHook.remove(); (void)g_drawColorToneHook.remove(); (void)g_renderTextureHook.remove(); (void)g_loadAreaHook.remove(); @@ -408,6 +921,19 @@ void prepare_for_shutdown() noexcept { // state are torn down. MinHook itself stays initialized until // uninstall_all(), after every MinHook-backed subsystem has removed its // hooks. + (void)g_textureUploadHook.disable(); + (void)g_drawFlushHook.disable(); + (void)g_renderFogHook.disable(); + (void)g_vidCellFrameSizeHook.disable(); + (void)g_vidCellCenterPointHook.disable(); + (void)g_vidCellGetFrameHook.disable(); + (void)g_particleRenderHook.disable(); + (void)g_particleUpdateHook.disable(); + for (auto& hook : g_objectRenderHooks) (void)hook.disable(); + (void)g_spriteHealthBarHook.disable(); + (void)g_spriteMarkersHook.disable(); + (void)g_spriteRenderHook.disable(); + (void)g_staticRenderHook.disable(); (void)g_drawColorToneHook.disable(); (void)g_renderTextureHook.disable(); (void)g_loadAreaHook.disable(); diff --git a/src/iee/shader_probe.cpp b/src/iee/shader_probe.cpp index 798dd58..975cf07 100644 --- a/src/iee/shader_probe.cpp +++ b/src/iee/shader_probe.cpp @@ -618,10 +618,20 @@ void use_program(unsigned program, std::uintptr_t caller, bool isArb) { } } +// _ReturnAddress() is an MSVC intrinsic; mingw-w64 declares it in intrin.h +// for source compatibility but never defines it, so cross builds hit an +// unresolved symbol at link time. GCC/Clang expose the same value via the +// __builtin_return_address() builtin instead. +#if defined(_MSC_VER) && !defined(__clang__) +static inline void* caller_return_address() noexcept { return _ReturnAddress(); } +#else +static inline void* caller_return_address() noexcept { return __builtin_return_address(0); } +#endif + static void APIENTRY detour_glUseProgram(unsigned program) noexcept { bool forwarded = false; try { - const auto caller = reinterpret_cast(_ReturnAddress()); + const auto caller = reinterpret_cast(caller_return_address()); forwarded = true; g_glUseProgramHook.original()(program); use_program(program, caller, false); @@ -633,7 +643,7 @@ static void APIENTRY detour_glUseProgram(unsigned program) noexcept { static void APIENTRY detour_glUseProgramObjectARB(unsigned program) noexcept { bool forwarded = false; try { - const auto caller = reinterpret_cast(_ReturnAddress()); + const auto caller = reinterpret_cast(caller_return_address()); forwarded = true; g_glUseProgramObjectARBHook.original()(program); use_program(program, caller, true); @@ -1080,12 +1090,20 @@ void set_override_effect_enabled(bool enabled) noexcept { uniforms::set_effect_e bool override_effect_enabled() noexcept { return uniforms::effect_enabled(); } +bool override_effect_replacement_enabled() noexcept { + return uniforms::effect_replacement_enabled(); +} + void set_area_world_size(float widthPx, float heightPx) noexcept { uniforms::set_world_size(widthPx, heightPx); } void set_area_water_tint(float r, float g, float b) noexcept { uniforms::set_water_tint(r, g, b); } +void set_area_effect_points(const float* xyzw, std::size_t count) noexcept { + uniforms::set_effect_points(xyzw, count); +} + void set_area_view(float scrollX, float scrollY, float viewWorldW, float viewWorldH) noexcept { uniforms::set_view(scrollX, scrollY, viewWorldW, viewWorldH); } diff --git a/src/iee/shader_probe.h b/src/iee/shader_probe.h index 21f04e7..1aec6d5 100644 --- a/src/iee/shader_probe.h +++ b/src/iee/shader_probe.h @@ -25,6 +25,9 @@ void on_frame_tick(float secondsSinceStart) noexcept; // Hotkey cycle target: uIeeEnabled value 0=off / 1=effect on / 2=alignment debug. void set_override_effect_enabled(bool enabled) noexcept; [[nodiscard]] bool override_effect_enabled() noexcept; +// Normal ON state only; false in ALIGN so authored BAMs remain visible under +// placement markers. +[[nodiscard]] bool override_effect_replacement_enabled() noexcept; // Published by area_state at area load; consumed by the uniform feed. void set_area_world_size(float widthPx, float heightPx) noexcept; @@ -33,6 +36,10 @@ void set_area_world_size(float widthPx, float heightPx) noexcept; // liquid overlay tiles, linear 0..1). Neutral 0.5 grey = unknown. void set_area_water_tint(float r, float g, float b) noexcept; +// Classified ambient-animation point effects of the current area: +// `count` vec4 records (world x, world y, kind, strength). nullptr/0 clears. +void set_area_effect_points(const float* xyzw, std::size_t count) noexcept; + // Published once per world frame from DrawColorTone(Seam); consumed by the // uniform feed. // viewWorldW/H = rViewPort size (world px visible); the feed derives the diff --git a/src/iee/shader_uniform_bridge.cpp b/src/iee/shader_uniform_bridge.cpp index 05807a8..d20fdb9 100644 --- a/src/iee/shader_uniform_bridge.cpp +++ b/src/iee/shader_uniform_bridge.cpp @@ -3,9 +3,13 @@ #include #include +#include #include +#include +#include #include "area_state.h" +#include "iee/core/logger.h" #include "iee/game/opengl_types.h" #include "iee/game/texture_units.h" #include "iee/water_textures.h" @@ -26,6 +30,14 @@ std::atomic g_waterTintB{0.5f}; std::atomic g_effectValue{0.0f}; std::atomic g_feedCount{0}; std::atomic g_stateRevision{1}; + +// Effect point set (area-scoped, written by area refresh threads, read by the +// render-thread feed). The array cannot be atomic; a mutex plus its own +// revision keeps the feed's copy coherent and cheap when unchanged. +std::mutex g_effectPointsMutex; +std::array g_effectPoints{}; +std::size_t g_effectPointCount{0}; +std::atomic g_effectPointsRevision{1}; std::atomic g_performanceCalls{0}; std::atomic g_performanceSkipped{0}; std::atomic g_performanceTextureBindPasses{0}; @@ -96,6 +108,7 @@ void reset() noexcept { g_effectValue.store(0.0f, std::memory_order_relaxed); g_performanceEnabled.store(false, std::memory_order_relaxed); g_feedCount.store(0, std::memory_order_relaxed); + set_effect_points(nullptr, 0); g_stateRevision.store(1, std::memory_order_release); (void)take_performance_stats(); } @@ -110,6 +123,11 @@ void set_effect_enabled(bool enabled) noexcept { bool effect_enabled() noexcept { return g_effectValue.load(std::memory_order_relaxed) >= 0.5f; } +bool effect_replacement_enabled() noexcept { + const float value = g_effectValue.load(std::memory_order_relaxed); + return value >= 0.5f && value < 1.5f; +} + float cycle_debug_effect() noexcept { const float current = g_effectValue.load(std::memory_order_relaxed); const float next = current < 0.5f ? 1.0f : (current < 1.5f ? 2.0f : 0.0f); @@ -138,6 +156,30 @@ void set_view(float scrollX, float scrollY, float viewWorldWidth, float viewWorl if (changed) advance_state_revision(); } +void set_effect_points(const float* xyzw, std::size_t count) noexcept { + try { + if (!xyzw) count = 0; + const auto clamped = (std::min)(count, kMaxEffectPoints); + std::lock_guard lock(g_effectPointsMutex); + if (clamped == g_effectPointCount && + (clamped == 0 || std::memcmp(g_effectPoints.data(), xyzw, + clamped * kEffectPointFloats * sizeof(float)) == 0)) { + return; + } + g_effectPointCount = clamped; + if (clamped > 0 && xyzw) { + std::memcpy(g_effectPoints.data(), xyzw, clamped * kEffectPointFloats * sizeof(float)); + } + std::fill( + g_effectPoints.begin() + static_cast(clamped * kEffectPointFloats), + g_effectPoints.end(), 0.0f); + g_effectPointsRevision.fetch_add(1, std::memory_order_release); + advance_state_revision(); + } catch (...) { + // A failed point update must never affect rendering. + } +} + Snapshot snapshot() noexcept { return { .effectValue = g_effectValue.load(std::memory_order_relaxed), @@ -181,10 +223,20 @@ void feed(unsigned program, Locations& locations) { locations.worldSizeInv = resolve_location(gl, program, locations.worldSizeInv, "uIeeWorldSizeInv"); locations.waterTint = resolve_location(gl, program, locations.waterTint, "uIeeWaterTint"); + locations.pointCount = resolve_location(gl, program, locations.pointCount, "uIeePointCount"); + // Array uniforms: GL reports the canonical name as "uIeePoints[0]" and some + // drivers only resolve that spelling; try it first, then the bare name. + if (locations.points == Locations::kUnresolved) { + locations.points = gl.glGetUniformLocation(program, "uIeePoints[0]"); + if (locations.points < 0) { + locations.points = gl.glGetUniformLocation(program, "uIeePoints"); + } + } locations.areaMask = resolve_location(gl, program, locations.areaMask, "uIeeAreaMask"); locations.normalMap = resolve_location(gl, program, locations.normalMap, "uIeeNormalMap"); locations.dudvMap = resolve_location(gl, program, locations.dudvMap, "uIeeDudvMap"); locations.foamMap = resolve_location(gl, program, locations.foamMap, "uIeeFoamMap"); + locations.noiseMap = resolve_location(gl, program, locations.noiseMap, "uIeeNoiseMap"); g_feedCount.fetch_add(1, std::memory_order_relaxed); const auto stateRevision = g_stateRevision.load(std::memory_order_acquire); @@ -197,11 +249,12 @@ void feed(unsigned program, Locations& locations) { bool boundTextures = false; if (effectValue >= 0.5f) { boundTextures = locations.areaMask >= 0 || locations.normalMap >= 0 || locations.dudvMap >= 0 || - locations.foamMap >= 0; + locations.foamMap >= 0 || locations.noiseMap >= 0; if (locations.areaMask >= 0 && !area::bind_area_texture()) { effectValue = 0.0f; } - if ((locations.normalMap >= 0 || locations.dudvMap >= 0 || locations.foamMap >= 0) && + if ((locations.normalMap >= 0 || locations.dudvMap >= 0 || locations.foamMap >= 0 || + locations.noiseMap >= 0) && !water::ensure_water_textures_bound()) { effectValue = 0.0f; } @@ -271,6 +324,32 @@ void feed(unsigned program, Locations& locations) { locations.lastWaterTintG = waterTintG; locations.lastWaterTintB = waterTintB; } + const auto pointsRevision = g_effectPointsRevision.load(std::memory_order_acquire); + if (locations.lastPointsRevision != pointsRevision && + (locations.pointCount >= 0 || locations.points >= 0)) { + std::array points{}; + std::size_t pointCount = 0; + { + std::lock_guard lock(g_effectPointsMutex); + points = g_effectPoints; + pointCount = g_effectPointCount; + } + if (locations.pointCount >= 0) { + gl.glUniform1f(locations.pointCount, static_cast(pointCount)); + } + if (locations.points >= 0 && gl.glUniform4fv) { + gl.glUniform4fv(locations.points, static_cast(kMaxEffectPoints * 2), points.data()); + } + if (locations.lastPointsRevision == 0) { + LOG_DEBUG( + "Point uniforms first feed: program={}, countLocation={}, pointsLocation={}, " + "glUniform4fv={}, count={}", + program, locations.pointCount, locations.points, gl.glUniform4fv != nullptr, + pointCount); + } + locations.lastPointsRevision = pointsRevision; + } + if (!locations.samplersInitialized && gl.glUniform1i) { if (locations.areaMask >= 0) gl.glUniform1i(locations.areaMask, static_cast(game::texture_units::AreaMask)); @@ -280,6 +359,8 @@ void feed(unsigned program, Locations& locations) { gl.glUniform1i(locations.dudvMap, static_cast(game::texture_units::WaterDudv)); if (locations.foamMap >= 0) gl.glUniform1i(locations.foamMap, static_cast(game::texture_units::WaterFoam)); + if (locations.noiseMap >= 0) + gl.glUniform1i(locations.noiseMap, static_cast(game::texture_units::EffectsNoise)); locations.samplersInitialized = true; } locations.lastAppliedRevision = stateRevision; diff --git a/src/iee/shader_uniform_bridge.h b/src/iee/shader_uniform_bridge.h index 6e91314..2269799 100644 --- a/src/iee/shader_uniform_bridge.h +++ b/src/iee/shader_uniform_bridge.h @@ -1,9 +1,15 @@ #pragma once +#include #include namespace iee::probe::uniforms { +// Point capacity; each point occupies two vec4 uniform slots, so the +// fpSEAM override declares uIeePoints[kMaxEffectPoints * 2]. +inline constexpr std::size_t kMaxEffectPoints = 32; +inline constexpr std::size_t kEffectPointFloats = 8; + struct Locations { static constexpr int kUnresolved = -2; @@ -14,15 +20,19 @@ struct Locations { int viewport{kUnresolved}; int worldSizeInv{kUnresolved}; int waterTint{kUnresolved}; + int pointCount{kUnresolved}; + int points{kUnresolved}; int areaMask{kUnresolved}; int normalMap{kUnresolved}; int dudvMap{kUnresolved}; int foamMap{kUnresolved}; + int noiseMap{kUnresolved}; bool samplersInitialized{}; bool viewInitialized{}; bool worldSizeInitialized{}; bool waterTintInitialized{}; + std::uint64_t lastPointsRevision{}; float lastScrollX{}; float lastScrollY{}; float lastViewWorldWidth{}; @@ -61,9 +71,15 @@ void reset() noexcept; void set_time(float secondsSinceStart) noexcept; void set_effect_enabled(bool enabled) noexcept; [[nodiscard]] bool effect_enabled() noexcept; +// True only for the normal effect-rendering state. ALIGN keeps the shader +// diagnostics active but must leave authored BAMs visible for comparison. +[[nodiscard]] bool effect_replacement_enabled() noexcept; [[nodiscard]] float cycle_debug_effect() noexcept; void set_world_size(float widthPx, float heightPx) noexcept; void set_water_tint(float r, float g, float b) noexcept; +// `xyzw` holds `count` vec4 point records (world x, world y, kind, strength). +// nullptr or count 0 clears the point set. +void set_effect_points(const float* xyzw, std::size_t count) noexcept; void set_view(float scrollX, float scrollY, float viewWorldWidth, float viewWorldHeight) noexcept; [[nodiscard]] Snapshot snapshot() noexcept; [[nodiscard]] FeedPerformanceStats take_performance_stats() noexcept; diff --git a/src/iee/water_textures.cpp b/src/iee/water_textures.cpp index e66a8dc..91ea5f3 100644 --- a/src/iee/water_textures.cpp +++ b/src/iee/water_textures.cpp @@ -46,10 +46,13 @@ struct Entry { unsigned unit; }; -constexpr std::array kEntries{{ +constexpr std::array kEntries{{ {"iee_water_normal", game::texture_units::WaterNormal}, {"iee_water_dudv", game::texture_units::WaterDudv}, {"iee_water_foam", game::texture_units::WaterFoam}, + // Tileable FBM octaves for the fire/smoke point effects (R/G smooth FBM, + // B high frequency, A low-frequency blobs). + {"iee_effects_noise", game::texture_units::EffectsNoise}, }}; std::mutex g_textureMutex; @@ -242,7 +245,7 @@ bool upload_all_to_current_context() { if (context == g_uploadBlockedContext) return false; core::GlStateGuard guard({game::texture_units::WaterNormal, game::texture_units::WaterDudv, - game::texture_units::WaterFoam}); + game::texture_units::WaterFoam, game::texture_units::EffectsNoise}); // Do not attribute an error left by preceding engine work to the first DDS // upload and then suppress retries for the lifetime of this GL context. game::gl::discard_errors(); diff --git a/tests/iee_tests.cpp b/tests/iee_tests.cpp index 142e3a7..7be94b2 100644 --- a/tests/iee_tests.cpp +++ b/tests/iee_tests.cpp @@ -19,9 +19,14 @@ #include "iee/core/pattern_scanner.h" #include "iee/core/performance_samples.h" #include "iee/features/tile_render.h" +#include "iee/game/animation_interp.h" +#include "iee/game/are_animations.h" +#include "iee/game/sprite_motion.h" #include "iee/game/area_texture.h" +#include "iee/game/object_statics.h" #include "iee/game/build_manifest.h" #include "iee/game/dds_texture.h" +#include "iee/game/draw_queue.h" #include "iee/game/eeex_doc_layouts_x64.h" #include "iee/game/file_formats.h" #include "iee/game/runtime_types_x64.h" @@ -29,6 +34,7 @@ #include "iee/game/tile_upscale.h" #include "iee/game/tis_palette.h" #include "iee/game/wed_runtime.h" +#include "iee/game/world_post_plan.h" namespace { int g_failures = 0; @@ -1204,17 +1210,15 @@ void test_scale_selection_precedence() { "Fallback should prefer deterministic table provenance over heuristics"); } - auto heuristicInfo = make_tile_info(0x80, 20000, 4096, 4096); - heuristicInfo.header = nullptr; - heuristicInfo.tileCount = 1; - const auto heuristicDetection = iee::game::detect_scale(heuristicInfo, 20000, manifest); - expect_true(heuristicDetection.has_value(), "Heuristics should still exist as a final fallback"); - if (heuristicDetection) { - expect_eq(heuristicDetection->scaleFactor, 4, - "Heuristic fallback should still detect upscaled tiles"); - expect_true(heuristicDetection->source == iee::game::ScaleDetectionSource::Heuristic, - "Final fallback should report heuristic provenance"); - } + // Large raw UVs and high texture ids are not scale signals: with no header + // and an unresolvable table, detection must fail closed (the render path + // then samples and delegates the tileset as standard 1x). + auto garbageInfo = make_tile_info(0x80, 20000, 4096, 4096); + garbageInfo.header = nullptr; + garbageInfo.tileCount = 1; + const auto garbageDetection = iee::game::detect_scale(garbageInfo, 20000, manifest); + expect_true(!garbageDetection.has_value(), + "Garbage UV/texture-id input must not produce a scale detection"); bool linearFlag = true; auto linearInfo = make_tile_info(iee::game::TisTileDimensions::Upscaled4x, 12000, @@ -1264,6 +1268,379 @@ void test_interface_contract_token_boundary() { expect_eq(failed.missingIdentifiers.size(), std::size_t{1}, "only sTex missing"); expect_eq(failed.missingIdentifiers[0], std::string("sTex"), "missing identifier is sTex"); } +void test_config_world_post() { + iee::core::EngineConfig defaults{}; + expect_true(!defaults.softFogOfWar && !defaults.bloom, "World post effects default off"); + expect_eq(defaults.softFogRadius, 24.0f, "Soft fog radius default"); + expect_eq(defaults.lightSpill, 1.5f, "Light spill default"); + expect_eq(defaults.softFogDrift, 10.0f, "Soft fog drift default"); + expect_eq(defaults.softFogSmoothing, 0.25f, "Soft fog smoothing default"); + expect_eq(defaults.heatShimmer, 1.5f, "Heat shimmer default"); + expect_true(!defaults.spriteUpscale, "Sprite upscaling defaults off"); + expect_eq(defaults.spriteSharpness, 0.5f, "Sprite sharpness default"); + expect_eq(defaults.bloomStrength, 0.35f, "Bloom strength default"); + + const auto tempPath = + std::filesystem::current_path() / "InfinityEngine-Enhancer-world-post-test.ini"; + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogOfWar = true\n"; + out << "SoftFogRadius = 40\n"; + out << "Bloom = true\n"; + out << "LightSpill = 3\n"; + out << "SoftFogDrift = 20\n"; + out << "SoftFogSmoothing = 0.5\n"; + out << "HeatShimmer = 4\n"; + out << "SpriteUpscale = true\n"; + out << "SpriteSharpness = 0.8\n"; + out << "BloomStrength = 0.5\n"; + } + iee::core::EngineConfig cfg{}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "World post keys should load"); + expect_true(cfg.softFogOfWar && cfg.bloom, "World post bools should parse"); + expect_eq(cfg.softFogRadius, 40.0f, "Soft fog radius should parse"); + expect_eq(cfg.lightSpill, 3.0f, "Light spill should parse"); + expect_eq(cfg.softFogDrift, 20.0f, "Soft fog drift should parse"); + expect_eq(cfg.softFogSmoothing, 0.5f, "Soft fog smoothing should parse"); + expect_eq(cfg.heatShimmer, 4.0f, "Heat shimmer should parse"); + expect_true(cfg.spriteUpscale, "Sprite upscaling should parse"); + expect_eq(cfg.spriteSharpness, 0.8f, "Sprite sharpness should parse"); + expect_eq(cfg.bloomStrength, 0.5f, "Bloom strength should parse"); + + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogRadius = 100000\n"; + out << "LightSpill = 700\n"; + out << "SoftFogDrift = -4\n"; + out << "SoftFogSmoothing = 99\n"; + out << "HeatShimmer = 500\n"; + out << "SpriteSharpness = 9\n"; + out << "BloomStrength = -3\n"; + } + cfg = {}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "Out-of-range values should load"); + expect_eq(cfg.softFogRadius, 256.0f, "Soft fog radius is clamped to its maximum"); + expect_eq(cfg.lightSpill, 8.0f, "Light spill is clamped to its maximum"); + expect_eq(cfg.softFogDrift, 0.0f, "Soft fog drift is clamped to 0"); + expect_eq(cfg.softFogSmoothing, 2.0f, "Soft fog smoothing is clamped to its maximum"); + expect_eq(cfg.heatShimmer, 12.0f, "Heat shimmer is clamped to its maximum"); + expect_eq(cfg.spriteSharpness, 1.0f, "Sprite sharpness is clamped to 1"); + expect_eq(cfg.bloomStrength, 0.0f, "Bloom strength is clamped to 0"); + + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Rendering]\n"; + out << "SoftFogRadius = nan\n"; + out << "LightSpill = inf\n"; + out << "BloomStrength = nan\n"; + } + cfg = {}; + expect_true(iee::core::ConfigManager::load(tempPath, cfg), "Non-finite values should load"); + expect_eq(cfg.softFogRadius, 24.0f, "A non-finite radius falls back to the default"); + expect_eq(cfg.lightSpill, 1.5f, "A non-finite light spill falls back to the default"); + expect_eq(cfg.bloomStrength, 0.35f, "A non-finite strength falls back to the default"); + + std::error_code error; + std::filesystem::remove(tempPath, error); +} + +void test_manifest_world_post_targets() { + const auto found = iee::game::find_manifest("BGEE 2.7.3.x"); + expect_true(found.has_value(), "The 2.7.3 manifest should be registered"); + if (!found) return; + const auto& manifest = found->get(); + expect_true(!manifest.patterns.renderFog.empty(), "2.7.3 should carry a RenderFog pattern"); + expect_true(!manifest.patterns.drawFlush.empty(), "2.7.3 should carry a DrawFlush pattern"); + expect_eq(manifest.referenceRvas.renderFog, std::uintptr_t{0x2A1B60}, "RenderFog reference RVA"); + expect_eq(manifest.referenceRvas.drawFlush, std::uintptr_t{0x42B350}, "DrawFlush reference RVA"); + expect_true(iee::game::current_manifest().patterns.renderFog.empty(), + "The 2.6.6 manifest has no world post targets"); + expect_true(!manifest.patterns.particleUpdate.empty() && !manifest.patterns.particleRender.empty(), + "2.7.3 should carry the particle update and render patterns"); + // The manifest is initialised positionally: a pattern in the wrong slot + // hooks the wrong function. Pin each pattern to its function's first bytes. + expect_true(manifest.patterns.renderFog.starts_with("48 89 5C 24 10 4C 89 44 24 18"), + "The RenderFog slot holds CInfinity::RenderFog's bytes"); + expect_true(manifest.patterns.drawFlush.starts_with("4C 8B DC 55 41 56 41 57 49 8D 6B D8"), + "The DrawFlush slot holds DrawFlush_GL's bytes"); + expect_true(manifest.patterns.particleUpdate.starts_with("48 89 5C 24 18 57 48 83 EC 20 8B 51 10"), + "The particle update slot holds CParticle::AsynchronousUpdate's bytes"); + expect_true(manifest.patterns.particleRender.starts_with("48 89 5C 24 18 48 89 54 24 10 55 56 57"), + "The particle render slot holds CParticle::Render's bytes"); + expect_true(manifest.patterns.textureUpload.starts_with("48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18"), + "The texture upload slot holds TexSubImage_GL's bytes"); + expect_eq(manifest.referenceRvas.particleUpdate, std::uintptr_t{0x423C30}, + "CParticle::AsynchronousUpdate reference RVA"); + expect_eq(manifest.referenceRvas.particleRender, std::uintptr_t{0x425BE0}, + "CParticle::Render reference RVA"); + bool smoothsFloatingText = false; + for (const auto& target : manifest.smoothedObjectRenders) { + if (target.name && std::string_view(target.name) == "CGameText::Render") { + smoothsFloatingText = target.referenceRva == 0x1F39A0 && !target.pattern.empty(); + } + } + expect_true(smoothsFloatingText, "2.7.3 should smooth floating text with the other objects"); +} + +void test_draw_queue() { + using namespace iee::game; + const auto found = find_manifest("BGEE 2.7.3.x"); + expect_true(found.has_value(), "The 2.7.3 manifest should be registered"); + if (!found) return; + const DrawQueueLayout& layout = found->get().drawQueue; + expect_true(layout.valid(), "2.7.3 should describe the engine draw queue"); + expect_true(!current_manifest().drawQueue.valid(), "2.6.6 has no draw queue layout"); + + // State word: blend enable is bit 8, source factor bits 9-12, destination + // factor bits 13-16; factor index 1 is GL_ONE, 6/7 are SRC_ALPHA and its inverse. + const auto state = [](bool blend, unsigned src, unsigned dst) { + return static_cast((blend ? 1u << 8 : 0u) | (src << 9) | (dst << 13) | 0x3u); + }; + expect_true(is_additive(layout, state(true, 4, 1)), "ONE_MINUS_DST_COLOR / ONE is additive"); + expect_true(is_additive(layout, state(true, 3, 1)), "SRC_COLOR / ONE is additive"); + expect_true(!is_additive(layout, state(true, 6, 7)), "Normal alpha blending is not additive"); + expect_true(!is_additive(layout, state(false, 4, 1)), "Blending switched off is not additive"); + + const std::array queue{{ + {state(true, 6, 7), 0, 6}, // normal sprite + {state(true, 4, 1), 6, 12}, // glow + {state(true, 4, 1), 18, -1}, // a clear command must never be replayed + {state(true, 4, 1), 18, 0}, // empty + {state(true, 3, 1), 18, 6}, // glow + }}; + std::array out{}; + expect_eq(collect_additive(layout, queue.data(), 5, out), std::size_t{2}, + "Only drawable additive commands are collected"); + expect_eq(out[0].primStart, 6, "Collected commands keep their order"); + expect_eq(out[1].primStart, 18, "Collected commands keep their vertex range"); + std::array tiny{}; + expect_eq(collect_additive(layout, queue.data(), 5, tiny), std::size_t{1}, + "Collection stops at the output capacity"); + expect_eq(collect_additive(layout, queue.data(), -3, out), std::size_t{0}, + "A negative count collects nothing"); + expect_eq(collect_additive(layout, queue.data(), 1 << 20, out), std::size_t{0}, + "A count beyond the engine's queue capacity collects nothing"); + expect_eq(collect_additive(layout, nullptr, 5, out), std::size_t{0}, + "A missing queue collects nothing"); + + // cmp [rip + disp32], r14d at function offset 0x24: 44 39 35 . + std::array code{}; + code[0x24] = 0x44; + code[0x25] = 0x39; + code[0x26] = 0x35; + const std::int32_t disp = 0x1000; + std::memcpy(&code[0x27], &disp, sizeof(disp)); + const auto base = reinterpret_cast(code.data()); + expect_eq(draw_queue_count_address(layout, base), base + 0x2B + 0x1000, + "The count address is the rip-relative target of the compare"); + code[0x25] = 0x00; + expect_eq(draw_queue_count_address(layout, base), std::uintptr_t{0}, + "An unexpected instruction yields no address"); + expect_eq(draw_queue_count_address(layout, 0), std::uintptr_t{0}, + "A missing function yields no address"); + expect_eq(draw_queue_commands_address(layout, 0x200000), std::uintptr_t{0x200000 - 0x180A8}, + "The command array sits a fixed distance before the count"); +} + +void test_tick_clock_and_particle_backstep() { + using namespace iee::game; + TickClock clock; + expect_eq(clock.phase(5.0), 1.0, "With no tick seen, things are drawn where the engine has them"); + + clock.on_update(10.0); + expect_eq(clock.phase(10.0), 0.0, "A tick starts a new step"); + expect_true(std::abs(clock.phase(10.0 + TickClock::kDefaultInterval / 2.0) - 0.5) < 1e-9, + "Half a default interval later the step is half done"); + expect_eq(clock.phase(10.5), 1.0, "The step ends after one interval and stays there"); + expect_eq(clock.phase(9.0), 0.0, "A time before the tick is clamped to the start"); + + // Hundreds of particles update within one tick: that burst is one tick. + clock.on_update(10.001); + clock.on_update(10.002); + expect_eq(clock.phase(10.0), 0.0, "Updates within a burst do not restart the step"); + clock.on_update(10.05); + expect_true(std::abs(clock.phase(10.075) - 0.5) < 1e-9, + "The observed interval between ticks sets the step length"); + + clock.on_update(20.0); // a 10 s gap is a pause, not a tick length + expect_true(std::abs(clock.phase(20.0 + TickClock::kDefaultInterval / 2.0) - 0.5) < 1e-9, + "An implausible interval falls back to the default"); + + const ParticleState falling{{1000, 2000, 300}, {40, -20, -8}, 0, false}; + expect_true(particle_draw_position(falling, 1.0) == ParticlePoint{1000, 2000, 300}, + "At the end of a step a particle is drawn at its logic position"); + expect_true(particle_draw_position(falling, 0.0) == ParticlePoint{960, 2020, 308}, + "At the start of a step it is drawn one tick back along its velocity"); + expect_true(particle_draw_position(falling, 0.5) == ParticlePoint{980, 2010, 304}, + "In between it is drawn part of the way"); + const ParticleState gravityOnly{{1000, 2000, 300}, {40, -20, 0}, 16, true}; + expect_true(particle_draw_position(gravityOnly, 0.0) == ParticlePoint{960, 2020, 316}, + "A gravity-only particle steps back by its gravity in height"); + expect_true(particle_draw_position(falling, std::numeric_limits::quiet_NaN()) == + ParticlePoint{1000, 2000, 300}, + "A NaN phase draws the logic position"); + expect_true(particle_draw_position(falling, 7.0) == ParticlePoint{1000, 2000, 300}, + "A phase past the end draws the logic position"); +} + +void test_sprite_atlas_lookup() { + using namespace iee::game; + const auto found = find_manifest("BGEE 2.7.3.x"); + expect_true(found.has_value(), "The 2.7.3 manifest should be registered"); + if (!found) return; + expect_true(found->get().spriteAtlas.valid(), "2.7.3 should describe the sprite atlas"); + expect_true(!found->get().patterns.textureUpload.empty(), "2.7.3 should carry the upload pattern"); + expect_eq(found->get().referenceRvas.textureUpload, std::uintptr_t{0x42D190}, + "TexSubImage_GL reference RVA"); + expect_true(!current_manifest().spriteAtlas.valid(), "2.6.6 has no sprite atlas layout"); + + // A small stand-in for the engine's GL state block, same shape, smaller gaps. + SpriteAtlasLayout layout{}; + layout.userStateBeforeCount = 0x64; + layout.texturesBeforeCount = 0x800; + layout.textureEntrySize = 0x28; + layout.atlasAfterCount = 0x80; + layout.atlasStride = 0x30; + layout.atlasCount = 2; + layout.widthOffset = 0x00; + layout.heightOffset = 0x04; + layout.texelsOffset = 0x20; + layout.textureIndexOffset = 0x28; + layout.textureShift = 21; + layout.textureMask = 0x1FF; + expect_true(layout.valid(), "The stand-in layout is complete"); + + std::vector block(0x1000, 0); + const auto count = reinterpret_cast(block.data()) + 0x800; + const auto put32 = [&](std::size_t offset, std::uint32_t value) { + std::memcpy(block.data() + offset, &value, sizeof(value)); + }; + const auto put64 = [&](std::size_t offset, std::uint64_t value) { + std::memcpy(block.data() + offset, &value, sizeof(value)); + }; + int texelsA = 0; + int texelsB = 0; + // Atlas 0: 1024x1024, texture index 3. Atlas 1: 1024x512, texture index 4. + put32(0x880 + 0x00, 1024); + put32(0x880 + 0x04, 1024); + put64(0x880 + 0x20, reinterpret_cast(&texelsA)); + put32(0x880 + 0x28, 3); + put32(0x8B0 + 0x00, 1024); + put32(0x8B0 + 0x04, 512); + put64(0x8B0 + 0x20, reinterpret_cast(&texelsB)); + put32(0x8B0 + 0x28, 4); + put32(3 * 0x28, 77); // GL name of texture 3 + put32(4 * 0x28, 88); + + put32(0x800 - 0x64, 4u << 21); // the engine has texture 4 selected + expect_eq(atlas_slot_for_upload(layout, count, &texelsB), 1, + "An upload of an atlas's own buffer to its own texture is recognised"); + expect_eq(atlas_slot_for_upload(layout, count, &texelsA), -1, + "The other atlas's buffer with the wrong texture selected is not"); + int other = 0; + expect_eq(atlas_slot_for_upload(layout, count, &other), -1, "Any other upload is ignored"); + expect_eq(atlas_slot_for_upload(layout, count, nullptr), -1, "A null buffer is ignored"); + expect_eq(atlas_slot_for_upload(layout, 0, &texelsB), -1, "An unresolved state block is ignored"); + + const auto atlas = atlas_info(layout, count, 1); + expect_eq(atlas.width, 1024, "The atlas width is read from its descriptor"); + expect_eq(atlas.height, 512, "The atlas height is read from its descriptor"); + expect_true(atlas.textureName != nullptr && *atlas.textureName == 88u, + "The atlas's GL texture name is found in the texture table"); + expect_true(atlas_info(layout, count, 2).textureName == nullptr, "An out-of-range slot has none"); + expect_true(atlas_info(layout, count, -1).textureName == nullptr, "A negative slot has none"); +} + +void test_world_pass_gate() { + using namespace iee::game; + WorldPassGate gate; + expect_true(!gate.open(), "Nothing is world before an area has been drawn"); + gate.on_frame(); + expect_true(!gate.open(), "A frame boundary alone does not open the world pass"); + + gate.on_area_drawn(); + expect_true(!gate.open(), "After the area is drawn the rest of the frame is UI"); + gate.on_frame(); + expect_true(gate.open(), "The frame after an area frame starts in the world pass"); + gate.on_area_drawn(); + expect_true(!gate.open(), "It closes again once that frame's area is drawn"); + + // The frame boundary can be reported more than once per presented frame. + gate.on_frame(); + gate.on_frame(); + expect_true(gate.open(), "A doubled frame boundary still opens the world pass"); + gate.on_area_drawn(); + gate.on_frame(); + gate.on_frame(); + gate.on_frame(); + expect_true(gate.open(), "A few boundaries without an area are tolerated"); + + for (int i = 0; i < 8; ++i) gate.on_frame(); + expect_true(!gate.open(), "With no area drawn for several frames, everything is UI"); +} + +void test_world_post_plan() { + using namespace iee::game; + + expect_true(half_extent({1920, 1080}) == Extent{960, 540}, "Half extent halves both sides"); + expect_true(half_extent({1, 1}) == Extent{1, 1}, "Half extent never reaches zero"); + expect_true(half_extent({0, 0}) == Extent{1, 1}, "A degenerate extent still yields 1x1"); + + expect_eq(available_blur_levels({1920, 1080}), 6, "A full-HD image supports the level cap"); + expect_eq(available_blur_levels({64, 64}), 3, "Levels stop before a side drops below 8"); + expect_eq(available_blur_levels({10, 10}), 0, "A tiny image supports no blur levels"); + expect_eq(available_blur_levels({0, 0}), 0, "A degenerate image supports no blur levels"); + + expect_true(untextured_corner({2048, 2048}) == Region{2040, 2040, 8, 8}, + "The untextured-draw texels sit in the last corner of the atlas copy"); + expect_true(untextured_corner({4, 2}) == Region{0, 0, 4, 2}, + "The corner region never leaves a small atlas"); + + const auto none = blur_plan_for_radius(0.0f, {1920, 1080}); + expect_eq(none.levels, 0, "Radius 0 means no blur"); + expect_eq(blur_plan_for_radius(std::numeric_limits::quiet_NaN(), {1920, 1080}).levels, 0, + "A NaN radius means no blur"); + expect_eq(blur_plan_for_radius(-5.0f, {1920, 1080}).levels, 0, "A negative radius means no blur"); + + const auto small = blur_plan_for_radius(4.0f, {1920, 1080}); + expect_eq(small.levels, 1, "A 4 px radius needs one level"); + expect_eq(small.offset, 1.0f, "A radius equal to the level reach uses offset 1"); + + const auto typical = blur_plan_for_radius(24.0f, {1920, 1080}); + expect_eq(typical.levels, 4, "A 24 px radius needs four levels (reach 32)"); + expect_eq(typical.offset, 0.75f, "Offset scales the reach down to the radius"); + + const auto huge = blur_plan_for_radius(1000.0f, {1920, 1080}); + expect_eq(huge.levels, 6, "Levels are capped by what the image supports"); + expect_eq(huge.offset, 1.5f, "Offset is clamped so taps never skip texels badly"); + + expect_eq(blur_plan_for_radius(24.0f, {10, 10}).levels, 0, + "No blur is planned on an image too small to hold a level"); + + expect_eq(bloom_levels({960, 540}), 5, "Bloom uses at most five levels"); + expect_eq(bloom_levels({40, 40}), 2, "Bloom levels shrink with the image"); + + expect_eq(temporal_blend(0.1f, 0.0f), 1.0f, "No smoothing time means the new image is shown at once"); + expect_eq(temporal_blend(0.0f, 0.25f), 0.0f, "No elapsed time keeps the shown image"); + expect_true(std::abs(temporal_blend(0.25f, 0.25f) - 0.6321f) < 0.001f, + "One smoothing time moves 63% of the way to the new image"); + expect_true(temporal_blend(0.01f, 0.25f) < temporal_blend(0.02f, 0.25f), + "A longer frame moves further"); + expect_eq(temporal_blend(0.8f, 0.25f), 1.0f, "After a long gap the history is stale and dropped"); + expect_eq(temporal_blend(-1.0f, 0.25f), 1.0f, "A negative step drops the history"); + expect_eq(temporal_blend(std::numeric_limits::quiet_NaN(), 0.25f), 1.0f, + "A NaN step drops the history"); + + expect_eq(pixels_per_world_pixel(3840.0f, 1920.0f), 2.0f, "Scale is viewport over world width"); + expect_eq(pixels_per_world_pixel(3840.0f, 0.0f), 1.0f, "An unknown world width falls back to 1"); + expect_eq(pixels_per_world_pixel(0.0f, 1920.0f), 1.0f, "An unknown viewport falls back to 1"); + expect_eq(pixels_per_world_pixel(100000.0f, 1.0f), 8.0f, "Scale is clamped to a sane maximum"); + expect_eq(pixels_per_world_pixel(std::numeric_limits::infinity(), 100.0f), 1.0f, + "A non-finite input falls back to 1"); +} + } // namespace void test_area_liquid_texture_packing() { @@ -1296,7 +1673,9 @@ void test_area_liquid_texture_packing_rejects_mismatch() { iee::game::WedAreaInfo wed{}; wed.baseWidth = 3; wed.baseHeight = 1; - wed.baseOverlayFlags = {0x00}; // wrong size + // assign() instead of a 1-element initializer list: GCC 13 -O2 emits a + // false-positive -Warray-bounds on the list's backing-array copy. + wed.baseOverlayFlags.assign(1, 0x00); // wrong size expect_true(!iee::game::pack_area_liquid_texture(wed).has_value(), "flag/dimension mismatch -> nullopt"); iee::game::WedAreaInfo empty{}; @@ -1330,9 +1709,14 @@ void test_fpseam_override_asset_contract() { // Our feed contract. for (const std::string_view name : {"uIeeEnabled", "uIeeTime", "uIeeScroll", "uIeeZoom", "uIeeViewport", "uIeeWorldSizeInv", - "uIeeWaterTint", "uIeeAreaMask", "uIeeNormalMap", "uIeeDudvMap", "uIeeFoamMap"}) { + "uIeeWaterTint", "uIeePointCount", "uIeePoints", "uIeeAreaMask", "uIeeNormalMap", + "uIeeDudvMap", "uIeeFoamMap", "uIeeNoiseMap"}) { expect_true(source.find(name) != std::string::npos, "fpSEAM override declares feed uniform"); } + // The uniform-array capacity in the shader must match the bridge/packing + // cap (two vec4 slots per point). + expect_true(source.find("uIeePoints[64]") != std::string::npos, + "fpSEAM point array capacity matches kMaxAreaEffectPoints * 2"); expect_true(source.find("#version") == std::string::npos, "no #version line (engine sources are ARB-era GLSL)"); expect_true( @@ -1350,6 +1734,684 @@ void test_fpseam_override_asset_contract() { "confirmed interior water should skip the shoreline filter"); } +void test_classify_area_animation() { + using iee::game::AreaAnimationKind; + using iee::game::classify_area_animation; + + expect_true(classify_area_animation("FLAMBIG", "") == AreaAnimationKind::Fire, + "FLAM* resrefs should classify as fire"); + expect_true(classify_area_animation("torch01", "") == AreaAnimationKind::Fire, + "Resref classification should be case-insensitive"); + expect_true(classify_area_animation("ZZANIM", "Village fireplace") == AreaAnimationKind::Fire, + "Authored names should classify when the resref does not"); + expect_true(classify_area_animation("FPIT1S", "FPIT1S") == AreaAnimationKind::Fire, + "Fire pits (BG2EE AR0406) should classify as fire"); + expect_true(classify_area_animation("FLMSW", "FLMSW") == AreaAnimationKind::Fire, + "Bare FLM* flame BAMs (BG1EE) should classify as fire"); + expect_true(classify_area_animation("FLMS", "Candle03") == AreaAnimationKind::Light, + "Candle-named flames (BG1EE FLMS family) are dim lights, not fires"); + expect_true(classify_area_animation("FLMM", "Sconce01") == AreaAnimationKind::Fire, + "Sconces are wall flames"); + expect_true(classify_area_animation("AR900WN1", "AR900WN1") == AreaAnimationKind::None, + "ARW[DN]* overlays are night/day shadow scenery (verified from frames)"); + expect_true(classify_area_animation("AR900WD1", "AR900WD1") == AreaAnimationKind::None, + "Day shadow overlays stay unclassified scenery"); + expect_true(classify_area_animation("FIM1YLN1", "FIM1YLN1") == AreaAnimationKind::Fire, + "FIM* yellow flames (verified from frames) classify as fire"); + expect_true(classify_area_animation("YSFLBLU2", "YSFLBLU2") == AreaAnimationKind::Fire, + "YSFL* blue flames (verified from frames) classify as fire"); + expect_true(classify_area_animation("AM003XA", "AM003XA") == AreaAnimationKind::Fire, + "The hearth overlay is an exact-resref fire"); + expect_true(classify_area_animation("AM5508C", "AM5508C") == AreaAnimationKind::Light, + "The glow orb overlay is an exact-resref light"); + expect_true(classify_area_animation("AM6004A", "AM6004A") == AreaAnimationKind::Smoke, + "The dark plume overlay is an exact-resref smoke"); + expect_true(classify_area_animation("AM0604A", "AM0604A") == AreaAnimationKind::Fountain, + "The tiered fountain overlay is an exact-resref fountain"); + expect_true(classify_area_animation("AM0202FL", "AM0202FL") == AreaAnimationKind::Light, + "The star glint overlay is a light, not a flame, despite the FL suffix"); + expect_true(classify_area_animation("SPLASH", "SPLASH") == AreaAnimationKind::Water, + "Splashes classify as water effects"); + expect_true(classify_area_animation("DS6000W3", "Waterfall") == AreaAnimationKind::Water, + "Waterfall names classify as water effects"); + expect_true(classify_area_animation("BD0130LL", "Lava_Left") == AreaAnimationKind::Lava, + "Lava names classify as lava"); + expect_true(classify_area_animation("FISH3S", "Fish") == AreaAnimationKind::Wildlife, + "Fish classify as wildlife"); + expect_true(classify_area_animation("FLIESS", "FLIESS") == AreaAnimationKind::Wildlife, + "Fly swarms classify as wildlife"); + expect_true(classify_area_animation("BUTRFLY", "BUTRFLY3") == AreaAnimationKind::Wildlife, + "Butterflies classify as wildlife"); + expect_true(classify_area_animation("BD5100M1", "Mist_BD5100M1") == AreaAnimationKind::Smoke, + "Authored mist folds into the smoke kind"); + expect_true(classify_area_animation("AMSTEAM1", "AMB_Pipe1A") == AreaAnimationKind::Smoke, + "Steam pipes (BG2EE AR3017) fold into the smoke kind"); + expect_true(classify_area_animation("BUBBLES2", "BUBBLES2") == AreaAnimationKind::Water, + "Bubbles (BG2EE sewers) classify as water effects"); + expect_true(classify_area_animation("AMOH7300", "Tank_Bubbles") == AreaAnimationKind::Water, + "Bubble-named overlays classify as water effects"); + expect_true(classify_area_animation("SMOKE2", "") == AreaAnimationKind::Smoke, + "SMOK* resrefs should classify as smoke"); + expect_true(classify_area_animation("ZZANIM", "chimney smoke") == AreaAnimationKind::Smoke, + "Chimney names should classify as smoke"); + expect_true(classify_area_animation("FOUNT1", "") == AreaAnimationKind::Fountain, + "FOUNT* resrefs should classify as fountain"); + expect_true(classify_area_animation("GLOW01", "") == AreaAnimationKind::Light, + "GLOW* resrefs should classify as light"); + expect_true(classify_area_animation("ZZANIM", "window light") == AreaAnimationKind::Light, + "Light names should classify as light"); + expect_true(classify_area_animation("ZZANIM", "lightning strike") == AreaAnimationKind::None, + "Lightning is weather, not an authored light source"); + expect_true(classify_area_animation("ZZANIM", "mystery") == AreaAnimationKind::None, + "Unknown entries must stay unclassified"); + expect_true(classify_area_animation("", "") == AreaAnimationKind::None, + "Empty input classifies as none"); +} + +void test_parse_are_animations() { + using namespace iee::game; + + ARE_Header_st header{}; + header.nFileType = 0x41455241; // "AREA" + header.nFileVersion = 0x302E3156; // "V1.0" + header.nAnimations = 2; + header.nAnimationsOffset = sizeof(ARE_Header_st); + + ARE_Animation_st fire{}; + const char fireName[] = "Fireplace big"; + std::memcpy(fire.szName.data(), fireName, sizeof(fireName) - 1); + fire.nX = 320; + fire.nY = 240; + fire.nHeight = 5; + fire.rrAnimation = {'F', 'L', 'A', 'M', 'B', 'I', 'G', 0}; + fire.nFlags = kAreAnimationFlagIsShown; + fire.nSchedule = 0x00FFFFFF; + + ARE_Animation_st unknown{}; + const char unknownName[] = "mystery"; + std::memcpy(unknown.szName.data(), unknownName, sizeof(unknownName) - 1); + unknown.rrAnimation = {'Z', 'Z', 'X', 'Y', 0, 0, 0, 0}; + unknown.nFlags = kAreAnimationFlagNotLightSource; + + std::vector bytes; + write_bytes(bytes, 0, &header, sizeof(header)); + write_bytes(bytes, sizeof(ARE_Header_st), &fire, sizeof(fire)); + write_bytes(bytes, sizeof(ARE_Header_st) + sizeof(ARE_Animation_st), &unknown, sizeof(unknown)); + + AreaAnimationsInfo info{}; + expect_true(parse_are_animations(bytes.data(), bytes.size(), info), + "A valid ARE V1.0 animation section should parse"); + expect_eq(info.animations.size(), std::size_t{2}, "Both animation records should be read"); + expect_true(info.animations[0].kind == AreaAnimationKind::Fire, + "The FLAM* record should classify as fire"); + expect_true(info.animations[0].resrefView() == "FLAMBIG", "Animation resref should round-trip"); + expect_true(info.animations[0].nameView() == "Fireplace big", + "Animation name should round-trip NUL-terminated"); + expect_eq(info.animations[0].x, std::uint16_t{320}, "Animation X coordinate should round-trip"); + expect_eq(info.animations[0].y, std::uint16_t{240}, "Animation Y coordinate should round-trip"); + expect_true(info.animations[0].isShown(), "Flag bit 0 should report as shown"); + expect_true(info.animations[0].isLightSource(), + "An animation without the not-light-source bit is a light source"); + expect_true(!info.animations[1].isShown(), "Missing flag bit 0 should report as not shown"); + expect_true(!info.animations[1].isLightSource(), + "The not-light-source bit should suppress light-source status"); + expect_eq(info.count_of(AreaAnimationKind::Fire), std::size_t{1}, + "count_of should tally classified kinds"); + expect_eq(info.count_of(AreaAnimationKind::None), std::size_t{1}, + "count_of should tally unclassified records"); + + // Zero animations is a valid area. + auto emptyHeader = header; + emptyHeader.nAnimations = 0; + emptyHeader.nAnimationsOffset = 0; + std::vector emptyBytes; + write_bytes(emptyBytes, 0, &emptyHeader, sizeof(emptyHeader)); + AreaAnimationsInfo emptyInfo{}; + expect_true(parse_are_animations(emptyBytes.data(), emptyBytes.size(), emptyInfo), + "An ARE without animations should parse"); + expect_true(emptyInfo.animations.empty(), "An ARE without animations should yield no records"); + + // Unsupported version (IWD2 V9.1 shifts the section offsets). + auto v91 = header; + v91.nFileVersion = 0x312E3956; // "V9.1" + std::vector v91Bytes(bytes); + write_bytes(v91Bytes, 0, &v91, sizeof(v91)); + AreaAnimationsInfo v91Info{}; + expect_true(!parse_are_animations(v91Bytes.data(), v91Bytes.size(), v91Info), + "Non-V1.0 ARE versions must fail closed"); + + // Truncated section: count says two records but only one fits. + std::vector truncated(bytes.begin(), + bytes.end() - static_cast(sizeof(fire))); + AreaAnimationsInfo truncatedInfo{}; + expect_true(!parse_are_animations(truncated.data(), truncated.size(), truncatedInfo), + "A truncated animation section must fail closed"); + expect_true(truncatedInfo.animations.empty(), "A failed parse must leave the output empty"); + + // Malicious count. + auto hugeHeader = header; + hugeHeader.nAnimations = 1'000'000; + std::vector hugeBytes(bytes); + write_bytes(hugeBytes, 0, &hugeHeader, sizeof(hugeHeader)); + AreaAnimationsInfo hugeInfo{}; + expect_true(!parse_are_animations(hugeBytes.data(), hugeBytes.size(), hugeInfo), + "An implausible animation count must fail closed"); + + AreaAnimationsInfo shortInfo{}; + expect_true(!parse_are_animations(bytes.data(), sizeof(ARE_Header_st) - 1, shortInfo), + "A buffer smaller than the ARE header must fail closed"); +} + +void test_decode_object_array_globals() { + using namespace iee::game; + + // Synthetic CGameObjectArray::GetShare body: manifest pattern prologue, + // then the RIP-relative max-index compare, the (ignored) next-id compare, + // and the entry-table lea, each pointing at slots inside the same buffer. + std::array code{}; + const auto put = [&](std::size_t offset, std::initializer_list bytes) { + std::size_t index = offset; + for (const auto value : bytes) code[index++] = static_cast(value); + }; + const auto putRip = [&](std::size_t offset, std::initializer_list opcode, + std::size_t target) { + put(offset, opcode); + const auto displacement = static_cast(static_cast(target) - + static_cast(offset + 7)); + std::memcpy(code.data() + offset + 3, &displacement, sizeof(displacement)); + }; + put(0, {0x48, 0xC7, 0x02, 0x00, 0x00, 0x00, 0x00, 0x83, 0xF9, 0xFF}); + putRip(10, {0x66, 0x39, 0x05}, 0x80); // cmp [rip+d], ax -> m_maxArrayIndex + putRip(17, {0x66, 0x39, 0x0D}, 0x84); // cmp [rip+d], cx -> ignored + putRip(24, {0x4C, 0x8D, 0x05}, 0x90); // lea r8, [rip+d] -> entry table + + ObjectArrayGlobals globals{}; + expect_true(decode_object_array_globals(code.data(), 0x60, globals), + "GetShare RIP operands should decode from a well-formed body"); + expect_true(reinterpret_cast(globals.maxArrayIndex) == code.data() + 0x80, + "The max-index compare operand should decode to its RIP target"); + expect_true(reinterpret_cast(globals.entries) == code.data() + 0x90, + "The entry-table lea operand should decode to its RIP target"); + + ObjectArrayGlobals tooSmall{}; + expect_true(!decode_object_array_globals(code.data(), 0x10, tooSmall), + "A window without both instructions must fail closed"); + + putRip(40, {0x66, 0x39, 0x05}, 0x88); // duplicate max-index compare + ObjectArrayGlobals ambiguous{}; + expect_true(!decode_object_array_globals(code.data(), 0x60, ambiguous), + "Ambiguous instruction matches must fail closed"); +} + +void test_collect_area_static_animations() { + using namespace iee::game; + + CGameArea areaA{}; + CGameArea areaB{}; + std::array statics{}; + + // A loaded two-cycle BAM V1: cycle 0 has two frames whose boxes differ, + // cycle 1 a wider one. The lookup list maps cycle slots to frame entries. + std::array bamFrames{}; + bamFrames[0] = {12, 40, 6, 30, 0}; // box -6,-30 .. 6,10 + bamFrames[1] = {16, 44, 9, 36, 0}; // box -9,-36 .. 7,8 + bamFrames[2] = {60, 10, 30, 5, 0}; // box -30,-5 .. 30,5 + std::array bamSequences{}; + bamSequences[0] = {2, 0}; + bamSequences[1] = {1, 2}; + std::array bamFrameList{1, 0, 2}; + bamHeader_st bamHeader{}; + bamHeader.nFrames = 3; + bamHeader.nSequences = 2; + std::byte bamData{}; + CResCell bam{}; + bam.baseclass_0.pData = &bamData; + bam.baseclass_0.bLoaded = true; + bam.m_pBamHeader = &bamHeader; + bam.m_pFrames = bamFrames.data(); + bam.m_pSequences = bamSequences.data(); + bam.m_pFrameList = bamFrameList.data(); + bam.m_nFrameList = 0; // as in the engine: CResCell::Parse leaves it unset + + statics[0].baseclass_0.m_objectType = kGameObjectTypeStatic; + statics[0].baseclass_0.m_pArea = &areaA; + statics[0].baseclass_0.m_posZ = 25; + statics[0].m_vidCell.pRes = &bam; + statics[0].m_header.rrAnimation = {'F', 'L', 'A', 'M', 'B', 'I', 'G', 0}; + const char fireName[] = "FLAMBIG"; + std::memcpy(statics[0].m_header.szName.data(), fireName, sizeof(fireName) - 1); + statics[0].m_header.nX = 320; + statics[0].m_header.nY = 240; + statics[0].m_header.nFlags = kAreAnimationFlagIsShown; + + // Same type, different area: filtered. + statics[1].baseclass_0.m_objectType = kGameObjectTypeStatic; + statics[1].baseclass_0.m_pArea = &areaB; + statics[1].m_header.rrAnimation = {'S', 'M', 'O', 'K', 'E', '2', 0, 0}; + + // Same area, different object type: filtered. + statics[2].baseclass_0.m_objectType = 0x31; + statics[2].baseclass_0.m_pArea = &areaA; + + std::array entries{}; + entries[1].m_objectPtr = &statics[0].baseclass_0; + entries[3].m_objectPtr = &statics[1].baseclass_0; + entries[4].m_objectPtr = &statics[2].baseclass_0; + + std::int16_t maxIndex = 5; + const ObjectArrayGlobals globals{entries.data(), &maxIndex}; + + AreaAnimationsInfo out{}; + expect_true(collect_area_static_animations(globals, &areaA, out), + "A readable object array should collect"); + expect_eq(out.animations.size(), std::size_t{1}, + "Only statics owned by the requested area should be collected"); + expect_true(!out.animations.empty() && out.animations[0].kind == AreaAnimationKind::Fire, + "Collected records should classify like the disk parser"); + expect_true(!out.animations.empty() && out.animations[0].x == 320 && + out.animations[0].isShown(), + "Collected records should carry the live header fields"); + expect_true(!out.animations.empty() && out.animations[0].objZ == 25, + "The walk mirrors the live m_posZ elevation"); + expect_true(!out.animations.empty() && out.animations[0].object == &statics[0], + "The walk records the live object as the envelope cache key"); + const auto box = out.animations.empty() ? BamEnvelope{} : out.animations[0].envelope; + expect_true(box.valid && box.left == -9 && box.top == -36 && box.right == 7 && box.bottom == 10, + "The walk unions the current cycle's frame boxes from the BAM frame table"); + + BamEnvelope direct{}; + statics[0].m_header.nFlags |= kAreAnimationFlagAllSequences; + expect_true(read_static_bam_envelope(&statics[0], direct) && direct.left == -30 && + direct.top == -36 && direct.right == 30 && direct.bottom == 10, + "A draw-all-sequences record unions every cycle"); + statics[0].m_header.nFlags &= ~kAreAnimationFlagAllSequences; + + bam.baseclass_0.bLoaded = false; + expect_true(!read_static_bam_envelope(&statics[0], direct) && !direct.valid, + "An unloaded BAM has no trustworthy frame table"); + bam.baseclass_0.bLoaded = true; + bamFrameList[0] = 7; + expect_true(!read_static_bam_envelope(&statics[0], direct), + "A lookup entry beyond the frame table fails closed"); + bamFrameList[0] = 1; + bamFrames[1].nWidth = 5000; + expect_true(!read_static_bam_envelope(&statics[0], direct), + "An implausible frame size fails closed"); + bamFrames[1].nWidth = 16; + expect_true(!read_static_bam_envelope(nullptr, direct) && + !read_static_bam_envelope(&statics[1], direct), + "A null object or a cell without a resource yields no envelope"); + + AreaAnimationsInfo invalidOut{}; + expect_true(!collect_area_static_animations(ObjectArrayGlobals{}, &areaA, invalidOut), + "Unresolved globals must fail closed"); + std::int16_t negativeIndex = -1; + const ObjectArrayGlobals negative{entries.data(), &negativeIndex}; + expect_true(!collect_area_static_animations(negative, &areaA, invalidOut), + "A negative max index must fail closed"); +} + +void test_sprite_motion_tracker() { + using namespace iee::game; + const double tick = SpriteMotionTracker::kDefaultInterval; + int a = 0; + int b = 0; + SpriteMotionTracker tracker; + + expect_true(tracker.sample(&a, {100, 200}, 10.0) == MotionPoint{100, 200}, + "A first sighting is drawn where the engine has it"); + expect_true(tracker.sample(&a, {100, 200}, 10.5) == MotionPoint{100, 200}, + "A stationary sprite is never moved"); + + // First step after standing still: slides over the default tick. + expect_true(tracker.sample(&a, {110, 200}, 11.0) == MotionPoint{100, 200}, + "A new logic position starts from what was on screen"); + expect_true(tracker.sample(&a, {110, 200}, 11.0 + tick / 2) == MotionPoint{105, 200}, + "Half a tick later the sprite is half way"); + expect_true(tracker.sample(&a, {110, 200}, 11.0 + tick / 2) == MotionPoint{105, 200}, + "Sampling twice in one frame is stable"); + expect_true(tracker.sample(&a, {110, 200}, 11.0 + tick * 3) == MotionPoint{110, 200}, + "It settles on the logic position and stays there"); + + // Steady walk at a slower logic rate: the observed interval is adopted. + const double slow = 1.0 / 20.0; + (void)tracker.sample(&a, {120, 200}, 12.0); + (void)tracker.sample(&a, {130, 200}, 12.0 + slow); + expect_true(tracker.sample(&a, {130, 200}, 12.0 + slow * 1.5) == MotionPoint{125, 200}, + "The slide spans the interval between the last two moves"); + + // A tick that arrives early continues from the on-screen position. + (void)tracker.sample(&b, {0, 0}, 20.9); + (void)tracker.sample(&b, {10, 0}, 21.0); + expect_true(tracker.sample(&b, {20, 0}, 21.0 + tick / 2) == MotionPoint{5, 0}, + "An early move never jumps backwards or forwards"); + + expect_true(tracker.sample(&b, {500, 500}, 21.3) == MotionPoint{500, 500}, + "A teleport-sized move is shown immediately"); + + // Back on screen after a gap (or a new object at a reused address). + expect_true(tracker.sample(&b, {510, 500}, 22.0 + SpriteMotionTracker::kForgetSeconds + 0.1) == + MotionPoint{510, 500}, + "A sprite not sampled for a while starts over instead of sliding"); + + SpriteMotionTracker fast{192}; + (void)fast.sample(&a, {0, 0}, 40.0); + (void)fast.sample(&a, {100, 0}, 40.1); + expect_true(fast.sample(&a, {100, 0}, 40.1 + tick / 2) == MotionPoint{50, 0}, + "A larger snap limit lets fast movers slide"); + + tracker.clear(); + expect_eq(tracker.size(), std::size_t{0}, "clear() forgets every sprite"); + for (std::size_t i = 0; i <= SpriteMotionTracker::kPruneThreshold; ++i) { + (void)tracker.sample(reinterpret_cast(i + 1), {0, 0}, 30.0); + } + (void)tracker.sample(&a, {0, 0}, 30.0 + SpriteMotionTracker::kStaleSeconds + 1.0); + expect_eq(tracker.size(), std::size_t{1}, "Sprites not drawn for a while are forgotten"); +} + +// Builds an uncompressed BAM V1 image: `frames` raw 1x1 frames and the given +// cycles (lists of frame indices). +std::vector make_raw_bam(std::uint16_t frames, + const std::vector>& cycles) { + using namespace iee::game; + std::vector lookup; + for (const auto& cycle : cycles) lookup.insert(lookup.end(), cycle.begin(), cycle.end()); + bamHeader_st header{}; + header.nFileType = 0x204D4142; + header.nFileVersion = 0x20203156; + header.nFrames = frames; + header.nSequences = static_cast(cycles.size()); + header.nTableOffset = sizeof(bamHeader_st); + header.nPaletteOffset = static_cast( + header.nTableOffset + frames * sizeof(frameTableEntry_st) + + cycles.size() * sizeof(sequenceTableEntry_st)); + header.nFrameListOffset = header.nPaletteOffset + 1024; + const auto dataOffset = + static_cast(header.nFrameListOffset + lookup.size() * sizeof(std::uint16_t)); + std::vector image(dataOffset + frames); + std::memcpy(image.data(), &header, sizeof(header)); + for (std::uint16_t index = 0; index < frames; ++index) { + frameTableEntry_st frame{1, 1, 0, 0, (dataOffset + index) | 0x80000000u}; + std::memcpy(image.data() + header.nTableOffset + index * sizeof(frame), &frame, sizeof(frame)); + } + std::uint16_t start = 0; + for (std::size_t index = 0; index < cycles.size(); ++index) { + sequenceTableEntry_st sequence{static_cast(cycles[index].size()), start}; + start = static_cast(start + cycles[index].size()); + std::memcpy(image.data() + header.nTableOffset + frames * sizeof(frameTableEntry_st) + + index * sizeof(sequence), + &sequence, sizeof(sequence)); + } + if (!lookup.empty()) { + std::memcpy(image.data() + header.nFrameListOffset, lookup.data(), + lookup.size() * sizeof(std::uint16_t)); + } + return image; +} + +void test_animation_interpolation() { + using namespace iee::game; + + // --- expanded BAM validation + auto good = make_raw_bam(5, {{0, 3, 1, 4, 2, 2}}); + ExpandedBamView view{}; + expect_true(parse_expanded_bam(good, view) && view.header->nFrames == 5 && + view.sequences[0].nFrames == 6 && view.frameList[1] == 3, + "A well-formed raw BAM V1 yields its table pointers"); + auto bad = good; + bad.resize(bad.size() - 1); + expect_true(!parse_expanded_bam(bad, view), "A frame whose pixels overrun the image is rejected"); + bad = make_raw_bam(5, {{0, 3, 1, 9, 2, 2}}); + expect_true(!parse_expanded_bam(bad, view), "A lookup entry beyond the frame table is rejected"); + auto sparse = make_raw_bam(5, {{0, 3, 1, 4, 2, 2}, {0xFFFF, 0xFFFF}}); + expect_true(parse_expanded_bam(sparse, view), + "The format's empty-slot marker (0xFFFF) is a valid lookup entry"); + bad = good; + frameTableEntry_st rle{}; + std::memcpy(&rle, bad.data() + sizeof(bamHeader_st), sizeof(rle)); + rle.___u4 &= 0x7FFFFFFFu; + std::memcpy(bad.data() + sizeof(bamHeader_st), &rle, sizeof(rle)); + expect_true(!parse_expanded_bam(bad, view), "An RLE frame cannot be bounded and is rejected"); + bad = good; + bad[0] = std::byte{'X'}; + expect_true(!parse_expanded_bam(bad, view), "A wrong signature is rejected"); + + // --- frame choice + AnimationFrameTracker tracker; + int key = 0; + const double step = AnimationFrameTracker::kDefaultInterval; + expect_eq(tracker.sample(&key, 0, 0, 3, 10.0), std::uint16_t{0}, "First sighting: original frame"); + expect_eq(tracker.sample(&key, 0, 1, 3, 10.0 + step), std::uint16_t{1}, + "Right after a step the in-between leading to the new frame is shown"); + expect_eq(tracker.sample(&key, 0, 1, 3, 10.0 + step + step * 0.6), std::uint16_t{2}, + "Past half the interval the new frame itself is shown"); + expect_eq(tracker.sample(&key, 0, 2, 3, 10.0 + step + step), std::uint16_t{3}, "Next step: next in-between"); + expect_eq(tracker.sample(&key, 0, 0, 3, 10.0 + step + step * 2), std::uint16_t{5}, + "A looping cycle wraps through the closing in-between"); + expect_eq(tracker.sample(&key, 0, 2, 3, 10.0 + step + step * 3), std::uint16_t{4}, + "A non-consecutive jump shows the original frame"); + expect_eq(tracker.sample(&key, 1, 1, 3, 10.0 + step + step * 4), std::uint16_t{2}, + "A sequence change starts over"); + + // --- draw-time swap + const auto directory = std::filesystem::temp_directory_path() / "iee_interp_test"; + std::filesystem::create_directories(directory); + { + std::ofstream file(directory / "TESTBAM.bam", std::ios::binary); + file.write(reinterpret_cast(good.data()), static_cast(good.size())); + } + auto originalImage = make_raw_bam(3, {{0, 1, 2}}); + ExpandedBamView originalView{}; + expect_true(parse_expanded_bam(originalImage, originalView), "Fixture BAM parses"); + CResCell original{}; + const char resref[] = "testbam"; + original.baseclass_0.resref = resref; + original.baseclass_0.pData = originalImage.data(); + original.baseclass_0.bLoaded = true; + original.m_pBamHeader = originalView.header; + original.m_pFrames = originalView.frames; + original.m_pSequences = originalView.sequences; + original.m_pFrameList = originalView.frameList; + CVidCell cell{}; + cell.pRes = &original; + cell.m_nCurrentFrame = 1; + int stale = 0; + cell.m_pFrame = &stale; + + AnimationInterpolator interp; + interp.set_directory(directory); + interp.touch(&cell, 1.0); + expect_true(cell.pRes == &original && cell.m_nCurrentFrame == 1, + "Outside a draw scope nothing is swapped"); + int owner = 0; + interp.begin_scope(&owner); + interp.touch(&cell, 1.0); + expect_true(cell.pRes == &original && cell.m_nCurrentFrame == 1, + "An owner's first scope only observes which cells are covered"); + interp.end_scope(); + interp.begin_scope(&owner); + interp.touch(&cell, 1.0); + expect_true(cell.pRes != &original && cell.pRes->m_pFrames != original.m_pFrames && + cell.pRes->baseclass_0.bLoaded && cell.m_nCurrentFrame == 2 && + cell.m_pFrame == nullptr, + "Inside a scope the cell points at the expanded BAM and the mapped frame"); + const auto* swapped = cell.pRes; + interp.touch(&cell, 1.0); + expect_true(cell.pRes == swapped && cell.m_nCurrentFrame == 2, "A second touch is a no-op"); + interp.end_scope(); + expect_true(cell.pRes == &original && cell.m_nCurrentFrame == 1 && cell.m_pFrame == nullptr, + "Closing the scope restores the logic resource and frame"); + expect_eq(interp.loaded_count(), std::size_t{1}, "The expanded file is read once"); + + cell.m_nCurrentFrame = 2; + interp.begin_scope(&owner); + interp.touch(&cell, 1.01); + expect_eq(cell.m_nCurrentFrame, std::int16_t{3}, "A logic step draws the in-between first"); + interp.end_scope(); + + cell.m_nCurrentFrame = 7; // outside the logic cycle + interp.begin_scope(&owner); + interp.touch(&cell, 1.02); + expect_true(cell.pRes == &original && cell.m_nCurrentFrame == 7, + "A frame outside the logic cycle is left to the engine"); + interp.end_scope(); + + CResCell other{}; + const char missing[] = "NOFILE"; + other = original; + other.baseclass_0.resref = missing; + CVidCell otherCell{}; + otherCell.pRes = &other; + // A creature with one uncovered cell is not interpolated at all, so its + // body never runs half a step behind its weapon. + cell.m_nCurrentFrame = 1; + int armed = 0; + for (int pass = 0; pass < 3; ++pass) { + interp.begin_scope(&armed); + interp.touch(&cell, 2.0 + pass); + interp.touch(&otherCell, 2.0 + pass); + expect_true(cell.pRes == &original && otherCell.pRes == &other, + "A creature with an uncovered cell draws every cell unchanged"); + interp.end_scope(); + } + std::error_code cleanup; + std::filesystem::remove_all(directory, cleanup); +} + +void test_build_area_effect_points() { + using namespace iee::game; + + AreaAnimationsInfo info{}; + const auto add = [&](AreaAnimationKind kind, const char* resref, std::uint16_t x, bool shown) { + AreaAnimationInfo animation{}; + animation.kind = kind; + animation.x = x; + animation.y = 100; + animation.objX = x; + animation.objY = 100; + animation.flags = shown ? kAreAnimationFlagIsShown : 0; + for (std::size_t c = 0; resref[c] != '\0' && c < 8; ++c) animation.resref[c] = resref[c]; + info.animations.push_back(animation); + }; + + add(AreaAnimationKind::Smoke, "CHIMSMK", 10, true); + add(AreaAnimationKind::Smoke, "AM6004A", 15, true); // authored plume art: no point + add(AreaAnimationKind::Fire, "FIRE_4", 20, true); + add(AreaAnimationKind::Fire, "flamblu2", 25, true); // live lowercase resref: blue + shift + add(AreaAnimationKind::Fire, "AM5204C", 28, true); // hearth overlay: glow only + add(AreaAnimationKind::Fire, "FIRE_4", 30, false); // hidden: excluded + add(AreaAnimationKind::Light, "FLMS", 40, true); + add(AreaAnimationKind::Wildlife, "FISH3S", 50, true); // no effect kind: excluded + add(AreaAnimationKind::Water, "SPLASH", 60, true); // water path: excluded + + // Engine-native geometry wins when the walk read the live frame entry. + { + AreaAnimationsInfo live{}; + AreaAnimationInfo animation{}; + animation.kind = AreaAnimationKind::Fire; + animation.objX = 100; + animation.objY = 200; + animation.objZ = 30; // mounted sconce: RenderBam draws at y - z + animation.flags = kAreAnimationFlagIsShown; + const char liveResref[] = "flamblu2"; + for (std::size_t c = 0; liveResref[c] != '\0'; ++c) animation.resref[c] = liveResref[c]; + animation.envelope = {true, -2, -12, 6, 3}; // off-centre box, 8 wide, 15 tall + live.animations.push_back(animation); + auto livePoints = build_area_effect_points(live); + expect_true(livePoints.size() == 1 && livePoints[0].x == 102.0f && + livePoints[0].y == 173.0f && livePoints[0].height == 15.0f && + livePoints[0].halfWidth == 4.0f && livePoints[0].reserved1 == 1.0f, + "The authored box moves the object origin to the flame bottom-center"); + + live.animations[0].flags |= kAreAnimationFlagMirror; + livePoints = build_area_effect_points(live); + expect_true(livePoints.size() == 1 && livePoints[0].x == 98.0f && + livePoints[0].y == 173.0f && livePoints[0].halfWidth == 4.0f, + "A mirrored record flips the box around the object X like RenderBam"); + + AreaAnimationInfo smoke{}; + smoke.kind = AreaAnimationKind::Smoke; + smoke.objX = 300; + smoke.objY = 400; + smoke.flags = kAreAnimationFlagIsShown; + const char smokeResref[] = "CHIMSMK"; + for (std::size_t c = 0; smokeResref[c] != '\0'; ++c) smoke.resref[c] = smokeResref[c]; + smoke.envelope = {true, -20, -90, 10, -10}; + live.animations.clear(); + live.animations.push_back(smoke); + livePoints = build_area_effect_points(live); + expect_true(livePoints.size() == 1 && livePoints[0].x == 295.0f && + livePoints[0].y == 390.0f && livePoints[0].height == 80.0f && + livePoints[0].halfWidth == 15.0f, + "Smoke plumes take their base and size from the authored box too"); + } + + const auto points = build_area_effect_points(info); + expect_eq(points.size(), std::size_t{5}, "Shown fire/light + replaceable smoke become points"); + expect_true(!points.empty() && points[0].kind == 1.0f && points[0].x == 20.0f && + points[0].y == 115.0f && points[0].height == 27.0f && + points[0].halfWidth == 7.0f, + "Fire points come first with authored BAM geometry"); + expect_true(points.size() >= 2 && points[1].kind == 1.0f && points[1].reserved1 == 1.0f && + points[1].x == 29.0f && points[1].y == 115.0f && + points[1].height == 15.0f && points[1].halfWidth == 4.0f, + "Blue flames carry their palette, footprint, and authored bottom-center offset"); + expect_true(points.size() >= 3 && points[2].kind == 1.0f && points[2].reserved1 == 2.0f, + "Overlay fires become glow-only points"); + expect_true(points.size() >= 4 && points[3].kind == 4.0f, + "Light points follow fire"); + expect_true(points.size() >= 5 && points[4].kind == 2.0f && points[4].x == 10.0f, + "Only standalone smoke BAMs become plume points"); + + AreaAnimationsInfo overflow{}; + for (int i = 0; i < 80; ++i) { + AreaAnimationInfo animation{}; + animation.kind = i < 40 ? AreaAnimationKind::Smoke : AreaAnimationKind::Fire; + animation.resref[0] = 'F'; + animation.resref[1] = 'L'; + animation.resref[2] = 'A'; + animation.resref[3] = 'M'; + if (i < 40) { + animation.resref[0] = 'S'; + animation.resref[1] = 'M'; + animation.resref[2] = 'O'; + animation.resref[3] = 'K'; + } + animation.flags = kAreAnimationFlagIsShown; + overflow.animations.push_back(animation); + } + const auto capped = build_area_effect_points(overflow); + expect_eq(capped.size(), kMaxAreaEffectPoints, "The point set is capped at the uniform size"); + expect_true(!capped.empty() && capped[0].kind == 1.0f, + "Under capacity pressure, fire wins over smoke"); +} + +void test_config_detection_section() { + const auto tempPath = + std::filesystem::current_path() / "InfinityEngine-Enhancer-detection-test.ini"; + { + std::ofstream out(tempPath, std::ios::trunc); + out << "[Detection]\n"; + out << "AreaAnimationScan = false\n"; + } + + iee::core::EngineConfig cfg{}; + expect_true(cfg.enableAreaAnimationScan, "The area animation scan should default to enabled"); + expect_true(iee::core::ConfigManager::load(tempPath, cfg), + "ConfigManager::load should parse the detection section"); + expect_true(!cfg.enableAreaAnimationScan, "AreaAnimationScan=false should disable the scan"); + + expect_true(iee::core::ConfigManager::save(tempPath, cfg), + "ConfigManager::save should persist the detection section"); + iee::core::EngineConfig reloaded{}; + expect_true(iee::core::ConfigManager::load(tempPath, reloaded), + "The saved detection section should reload"); + expect_true(!reloaded.enableAreaAnimationScan, "AreaAnimationScan should round-trip"); + + std::error_code error; + std::filesystem::remove(tempPath, error); +} + int main() { test_parse_ida_pattern(); test_unique_pattern_matching(); @@ -1380,12 +2442,27 @@ int main() { test_tile_table_detection_ignores_garbage_steps(); test_tile_table_detection_uses_coordinate_deltas(); test_manifest_infgame_offsets(); + test_manifest_world_post_targets(); test_shader_name_extraction(); test_interface_contract(); test_interface_contract_token_boundary(); test_area_liquid_texture_packing(); test_area_liquid_texture_packing_rejects_mismatch(); test_fpseam_override_asset_contract(); + test_classify_area_animation(); + test_parse_are_animations(); + test_decode_object_array_globals(); + test_collect_area_static_animations(); + test_sprite_motion_tracker(); + test_animation_interpolation(); + test_world_post_plan(); + test_world_pass_gate(); + test_tick_clock_and_particle_backstep(); + test_draw_queue(); + test_sprite_atlas_lookup(); + test_build_area_effect_points(); + test_config_detection_section(); + test_config_world_post(); if (g_failures != 0) { std::cerr << g_failures << " test(s) failed\n"; diff --git a/tools/InfinityEngine-Enhancer.sample.ini b/tools/InfinityEngine-Enhancer.sample.ini index 1c348f6..f10baa3 100644 --- a/tools/InfinityEngine-Enhancer.sample.ini +++ b/tools/InfinityEngine-Enhancer.sample.ini @@ -10,6 +10,46 @@ PerformanceLogs = false EnableAnisotropicFiltering = false MaxAnisotropy = 8.0 LODBias = -0.25 +; Draw creatures gliding between the engine's logic ticks instead of stepping +; at the logic rate. Also smooths projectiles, floating text, rain, snow and +; sparkles. Only useful with EEex "Uncap FPS"; BGEE 2.7.3 only. +SmoothSpriteMovement = false +; Draw creature animations with the in-between frames found in iee-interp/ +; (expanded BAMs, one per original resref). Animations without an expanded +; file are unaffected. Only useful with EEex "Uncap FPS"; BGEE 2.7.3 only. +InterpolateAnimations = false +; Smooth the fog-of-war edge. SoftFogRadius is the blur in world pixels; +; 0 keeps the engine's own edge. BGEE 2.7.3 only. With EnableDebugHotkeys, +; F8 toggles it. +SoftFogOfWar = false +SoftFogRadius = 24 +; How far (world pixels) the soft edge drifts, like slow mist; 0 = still. +SoftFogDrift = 10 +; Seconds the fog takes to follow a visibility change, so it fades instead of +; stepping on the engine's logic tick; 0 = follow at once. +SoftFogSmoothing = 0.25 +; Glow around the engine's light-emitting effects (fires, torches, spells), +; never the UI or merely bright art. BloomStrength is the halo; LightSpill is +; how much those effects brighten and tint what is around them (0 = off). +; BGEE 2.7.3 only. With EnableDebugHotkeys, F9 toggles it. +Bloom = false +BloomStrength = 0.35 +LightSpill = 1.5 +; Heat haze above fires and other warm light sources: how far (world pixels) +; the air ripples; 0 = off. Needs Bloom. +HeatShimmer = 1.5 +; Smoother creature and effect sprites: an edge-adaptive 2x upscale (FSR1) of +; the engine's sprite sheet before it is drawn in the world view. It rounds off +; stair-stepped outlines; it cannot add detail. SpriteSharpness (0..1) is the +; strength of FSR1's sharpening pass. BGEE 2.7.3 only. With +; EnableDebugHotkeys, F7 toggles it. +SpriteUpscale = false +SpriteSharpness = 0.5 + +[Detection] +; Collect the current area's authored ambient animations from engine memory +; and classify fire/smoke/fountain/light point sources. +AreaAnimationScan = true [Shaders] ; Diagnostic only: archive original shaders to iee-shader-dumps/ next to the DLL. @@ -18,3 +58,6 @@ DumpEngineShaders = false EnableDebugHotkeys = false ; Start the bundled water shader enabled when it is installed in the game's override directory. EnableWaterEffect = true +; Fire glow/heat shimmer, chimney-smoke haze, and candle glows placed from the +; area's classified ambient animations (requires the fpSEAM override). +EnablePointEffects = true