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..44ccc11 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 @@ -59,6 +61,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..3c6a360 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,10 +92,12 @@ add_library(iee_common STATIC src/iee/core/config.cpp src/iee/core/logger.cpp src/iee/core/pattern_scanner.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/game_addrs.cpp + src/iee/game/object_statics.cpp src/iee/game/resref_runtime.cpp src/iee/game/shader_override.cpp src/iee/game/tile_liquid.cpp @@ -189,17 +191,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..aea2d12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,24 @@ 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/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 +89,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 +129,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..2254534 --- /dev/null +++ b/docs/are-animation-detection.md @@ -0,0 +1,157 @@ +# 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` +anchors at the live `CGameObject::m_pos` with the screen Y lifted by the +`m_posZ` elevation (`y = m_pos.y - m_posZ`) — mounted flames (wall sconces) +carry authored Z, so the packed point subtracts it the same way. + +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/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..515c553 100644 --- a/src/iee/area_state.cpp +++ b/src/iee/area_state.cpp @@ -6,14 +6,18 @@ #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" @@ -64,6 +68,169 @@ 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={}), frame=({}x{} c{},{} 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.frameWidth, + animation.frameHeight, + animation.frameCenterX, animation.frameCenterY, animation.frameValid, + 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; + 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; + } + 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 +342,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 +366,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) || @@ -376,6 +548,7 @@ void reset_gpu_area_state() noexcept { 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/core/config.cpp b/src/iee/core/config.cpp index bef334a..24c96d4 100644 --- a/src/iee/core/config.cpp +++ b/src/iee/core/config.cpp @@ -97,6 +97,12 @@ static void apply_kv(EngineConfig& cfg, const std::string& section, const std::s return; } + // [Detection] + if (iequals(section, "detection")) { + if (iequals(key, "AreaAnimationScan")) assign_bool(cfg.enableAreaAnimationScan); + return; + } + // [Shaders] if (iequals(section, "shaders")) { if (iequals(key, "DumpEngineShaders")) @@ -105,6 +111,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; } } @@ -182,10 +190,14 @@ bool ConfigManager::save(const std::filesystem::path& path, const EngineConfig& f << "MaxAnisotropy = " << cfg.maxAnisotropy << "\n"; f << "LODBias = " << cfg.lodBias << "\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..50f1d98 100644 --- a/src/iee/core/config.h +++ b/src/iee/core/config.h @@ -11,6 +11,9 @@ struct EngineConfig { 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/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/game/are_animations.cpp b/src/iee/game/are_animations.cpp new file mode 100644 index 0000000..3cd7f2b --- /dev/null +++ b/src/iee/game/are_animations.cpp @@ -0,0 +1,394 @@ +#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::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; +} + +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; + } + // RenderBam places the frame's top-left at objectPos - frameCenter, + // while the procedural flame grows upward from its bottom-center. Move + // the point to that authored bottom-center before suppressing the BAM. + // Note: while the effect suppresses the engine draw, CVidCell::m_pFrame + // normally stays null, so the reviewed per-resref table is the normal + // path for replaced flames. + const auto upper = upper_copy(resref); + point.reserved1 = upper.find("BLU") != std::string::npos ? 1.0f : 0.0f; // palette id + if (animation.frameValid) { + point.height = static_cast(animation.frameHeight); + point.halfWidth = + (std::max)(static_cast(animation.frameWidth) / 2.0f, 1.5f); + point.x += static_cast(animation.frameWidth) / 2.0f - + static_cast(animation.frameCenterX); + point.y += static_cast(animation.frameHeight - animation.frameCenterY); + } else { + const auto& geometry = flame_geometry_for(upper); + point.height = geometry.height; + point.halfWidth = geometry.halfWidth; + point.x += 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; + 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..d13c2dc --- /dev/null +++ b/src/iee/game/are_animations.h @@ -0,0 +1,130 @@ +#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 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{}; + // The engine's cached current BAM frame entry (CVidCell::m_pFrame) — the + // exact geometry RenderBam draws with. Valid only when the memory walk + // could read it (the object has rendered at least once). + bool frameValid{}; + std::int16_t frameWidth{}; + std::int16_t frameHeight{}; + std::int16_t frameCenterX{}; + std::int16_t frameCenterY{}; + 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; +}; + +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..0d03452 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,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", + // 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", }, - {0x27EBD0, 0x4257C0}, + {0x27EBD0, 0x4257C0, 0x276700, 0x1F27D0}, {0x100, 0x1DC, 0x14, 0x6590, 0x6598, 0x65F8}, {{ {"CRes_Demand", 0x36, BranchInstructionKind::CallRel32, 0xE8, 1, 5, true}, @@ -194,11 +208,19 @@ 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(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..54ff179 100644 --- a/src/iee/game/build_manifest.h +++ b/src/iee/game/build_manifest.h @@ -31,11 +31,23 @@ struct BranchInstructionDesc { 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{}; }; 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{}; }; struct RuntimeOffsets { diff --git a/src/iee/game/file_formats.h b/src/iee/game/file_formats.h index 3372885..e8e4881 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,80 @@ 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; + 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{}; @@ -171,6 +246,12 @@ 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); diff --git a/src/iee/game/game_addrs.cpp b/src/iee/game/game_addrs.cpp index cfeb8b2..2aaa1ab 100644 --- a/src/iee/game/game_addrs.cpp +++ b/src/iee/game/game_addrs.cpp @@ -62,6 +62,24 @@ 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 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..3a3f16e 100644 --- a/src/iee/game/game_addrs.h +++ b/src/iee/game/game_addrs.h @@ -11,6 +11,9 @@ namespace iee::game { 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; 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..c7b118f --- /dev/null +++ b/src/iee/game/object_statics.cpp @@ -0,0 +1,141 @@ +#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; +} + +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; + } + // The engine caches the current frame entry it renders with + // (CVidCell::m_pFrame); mirror its geometry so no per-resref table is + // needed. Null until the object has rendered once — the render-thread + // re-refresh after a transition sees it populated. + void* framePointer = nullptr; + frameTableEntry_st frame{}; + if (core::safe_read( + objectBytes + offsetof(CGameStatic, m_vidCell) + offsetof(CVidCell, m_pFrame), + framePointer) && + framePointer && core::safe_read(framePointer, frame) && frame.nWidth > 0 && + frame.nHeight > 0) { + info.frameValid = true; + info.frameWidth = static_cast(frame.nWidth); + info.frameHeight = static_cast(frame.nHeight); + info.frameCenterX = frame.nCenterX; + info.frameCenterY = frame.nCenterY; + } + 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..127da07 --- /dev/null +++ b/src/iee/game/object_statics.h @@ -0,0 +1,39 @@ +#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; + +// 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..1d4e91f 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 { @@ -150,6 +150,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( diff --git a/src/iee/game/opengl_types.h b/src/iee/game/opengl_types.h index 8767b32..b1f9a43 100644 --- a/src/iee/game/opengl_types.h +++ b/src/iee/game/opengl_types.h @@ -92,6 +92,7 @@ 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, @@ -144,6 +145,7 @@ struct OpenGLFunctions { PFN_glUniform1i glUniform1i{}; PFN_glUniform2f glUniform2f{}; PFN_glUniform3f glUniform3f{}; + PFN_glUniform4fv glUniform4fv{}; PFN_glActiveTexture glActiveTexture{}; PFN_glCompressedTexImage2D glCompressedTexImage2D{}; PFN_glGenerateMipmap glGenerateMipmap{}; diff --git a/src/iee/game/runtime_types_x64.h b/src/iee/game/runtime_types_x64.h index 19fa404..86a2010 100644 --- a/src/iee/game/runtime_types_x64.h +++ b/src/iee/game/runtime_types_x64.h @@ -395,6 +395,44 @@ 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{}; + }; + + // 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 caches the + // current BAM frame entry (m_pFrame) that RenderBam draws with. + 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{}; @@ -498,6 +536,17 @@ 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(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/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/hooks.cpp b/src/iee/hooks.cpp index b995af1..740e382 100644 --- a/src/iee/hooks.cpp +++ b/src/iee/hooks.cpp @@ -23,6 +23,7 @@ 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,6 +32,7 @@ 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; static AppContext* g_ctx = nullptr; @@ -275,6 +277,32 @@ static void detour_draw_color_tone(int mode) { g_drawColorToneHook.original()(mode); } +// 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); + if (game::should_replace_animation_draw(info.resrefView(), info.kind)) { + 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 +378,20 @@ 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"); + } + } + g_loadAreaHook.enable(); LOG_INFO("LoadArea hook enabled"); @@ -364,6 +406,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 +416,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 +433,7 @@ void uninstall_all() noexcept { } catch (...) { } + (void)g_staticRenderHook.remove(); (void)g_drawColorToneHook.remove(); (void)g_renderTextureHook.remove(); (void)g_loadAreaHook.remove(); @@ -408,6 +453,7 @@ 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_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..daf49cf 100644 --- a/tests/iee_tests.cpp +++ b/tests/iee_tests.cpp @@ -19,7 +19,9 @@ #include "iee/core/pattern_scanner.h" #include "iee/core/performance_samples.h" #include "iee/features/tile_render.h" +#include "iee/game/are_animations.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/eeex_doc_layouts_x64.h" @@ -1204,17 +1206,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, @@ -1296,7 +1296,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 +1332,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 +1357,398 @@ 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{}; + + frameTableEntry_st liveFrame{}; + liveFrame.nWidth = 12; + liveFrame.nHeight = 40; + liveFrame.nCenterX = 6; + liveFrame.nCenterY = 30; + + 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.m_pFrame = &liveFrame; + 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].frameValid && + out.animations[0].frameWidth == 12 && out.animations[0].frameHeight == 40 && + out.animations[0].frameCenterX == 6 && out.animations[0].frameCenterY == 30, + "The walk mirrors the engine's cached CVidCell frame geometry"); + + 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_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.frameValid = true; + animation.frameWidth = 8; + animation.frameHeight = 15; + animation.frameCenterX = 0; + animation.frameCenterY = 0; + live.animations.push_back(animation); + const auto livePoints = build_area_effect_points(live); + expect_true(livePoints.size() == 1 && livePoints[0].x == 104.0f && + livePoints[0].y == 185.0f && livePoints[0].height == 15.0f && + livePoints[0].halfWidth == 4.0f && livePoints[0].reserved1 == 1.0f, + "Live frame geometry moves the object origin to the flame bottom-center"); + } + + 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(); @@ -1386,6 +1785,12 @@ int main() { 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_build_area_effect_points(); + test_config_detection_section(); 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..a1d9005 100644 --- a/tools/InfinityEngine-Enhancer.sample.ini +++ b/tools/InfinityEngine-Enhancer.sample.ini @@ -11,6 +11,11 @@ EnableAnisotropicFiltering = false MaxAnisotropy = 8.0 LODBias = -0.25 +[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. DumpEngineShaders = false @@ -18,3 +23,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